**Phase 35 final verification pass — complete.** - Both task files (01, 02) already in `complete/`; verified the work in-tree: `#engines` = `grid cols-2`, `.strip` has no `align-items` (keeps `display: grid`, `gap: .85rem`, `auto-fit minmax(min(340px, 100%), 1fr)`), `.grid.items-start` rule + corrected comment intact, rewritten pin `test_llm_engine_windows_stretch_to_a_common_row_height` in place. - Live re-verified in browser (fresh `35-llm-verify.cjs` run, screenshots re-captured): both engines answering; strip cards 456 px / panel cards 422 px with flat equal bottoms; row gaps 13.6 px (uniform `.85rem`); `n/a` cells preserved; 360 px viewport single-column, no overflow/clip. `/system` no-regress: `grid auto items-start`, expanded disclosure does not stretch collapsed siblings (325.4 → 325.4). - Completion criteria: served `/llm` markup ✓ · live equalized rows + screenshot ✓ · `/system` bytes unchanged ✓ · full suite/coverage/lint ✓ · README/AGENTS.md ✓ · no behaviour change (all existing pins pass) ✓. - Tests: `bash .agents/validate.sh` → **exit 0**: `uv run pytest --cov` **2031 passed, 19 skipped** (15 live + 4 store_perf, opt-in), coverage **98.71%** (94% floor reached); `ruff` all checks passed; `pyright` 0 errors; `node --check` + repo hygiene clean. - Live suite (`--live`, read-only): 4/15 pass (LLM engines); 5 fail solely on host-agent/upsd **connectivity timeouts** (9120/3051 unreachable from this machine — same errors as `dev-server.sh status`); environmental, no code defect. No live changes made (all verification read-only). - Docs: README.md reviewed in full — verified quickstart, installer/agent flags, config defaults, Containerfile facts, script flags, fixtures, test files, polling cadences: **consistent, no changes needed**. AGENTS.md: fixed one stale statement (hermetic skip count "12 live" → "15 live"); nothing else stale. - Dev server up (pid 811266), `/api/health` 200; hosts/upsd DOWN is the pre-existing environmental state, LLM source ok. - Next pending phase: **none** — `todo/` contains only this phase (35).
11296 lines
661 KiB
Python
11296 lines
661 KiB
Python
"""HTTP contract: what the pages may assume, and what must never leak.
|
|
|
|
The behaviours here are the ones a screenshot cannot show: an unconfigured source answering
|
|
404 `configured: false` instead of dialling a hostname that does not exist; a bearer token
|
|
that stops at the backend; the single write endpoint refusing on every gate; no external
|
|
asset the network can fail to deliver.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import contextlib
|
|
import inspect
|
|
import ipaddress
|
|
import itertools
|
|
import json
|
|
import re
|
|
import time
|
|
from collections.abc import Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
from cryptography import x509
|
|
from cryptography.x509.oid import NameOID
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
import store_perf
|
|
from auth_client import sign_in
|
|
from auth_contract import ADMIN_PASSWORD, FAST_PBKDF2
|
|
from card_contract import HOST_SHAPES, assert_card_contract
|
|
from dashboards import alerts, api, auth, certs
|
|
from dashboards import app as app_module
|
|
from dashboards.api import MIN_PASSWORD_LENGTH, RANGE_SECONDS, _disk_visible, parse_range
|
|
from dashboards.app import create_app
|
|
from dashboards.collectors import UpstreamError
|
|
from dashboards.config import Settings, Target
|
|
from dashboards.rates import MIN_WINDOW_SECONDS, RateEstimator
|
|
from dashboards.severity import classify
|
|
from dashboards.sources import gateway as gateway_source
|
|
from dashboards.sources import nut
|
|
from dashboards.sources.disks import SMART_SERIES, DisksCollector, parse_disks_snapshot
|
|
from dashboards.sources.host import HostCollector, parse_host_snapshot
|
|
from dashboards.sources.llm import (
|
|
HISTORY_METRICS,
|
|
engine_result,
|
|
parse_gufo_metrics,
|
|
parse_radiance_metrics,
|
|
parse_vllm_metrics,
|
|
)
|
|
from dashboards.store import RES_RAW, Event, Sample, SampleStore
|
|
from rate_window_contract import LLM_WINDOW, delta_label
|
|
from slots_contract import (
|
|
CANONICAL_TASK_ID_FIELD,
|
|
OPTIONAL_NEXT_TOKEN_FIELDS,
|
|
OPTIONAL_ROW_FIELDS,
|
|
REQUIRED_NEXT_TOKEN_FIELDS,
|
|
REQUIRED_ROW_FIELDS,
|
|
TASK_ID_ALIASES,
|
|
USER_CONTENT_FIELDS,
|
|
load_slots_capture,
|
|
)
|
|
|
|
# refused immediately, and never dials out of the test environment
|
|
DEAD_URL = "http://127.0.0.1:1"
|
|
|
|
#: Every dashboard route, in nav order. The markup pins below (no CDN, no `.sev-*`, no
|
|
#: narration, no orphan `id`) run across this list rather than a hand-written four, so a new page
|
|
#: cannot join the masthead and skip the checks the others had to pass.
|
|
#: `/gateway` joined it in phase 29 task 05, which is the moment it became the **sixth** dashboard
|
|
#: rather than a route with a page: this tuple is the tab table, so entering it means the tab, its
|
|
#: order, and its `aria-current` are pinned on every signed-in page from here on.
|
|
PAGES = ("/", "/system", "/disks", "/llm", "/ups", "/gateway")
|
|
|
|
#: `/settings` is a **page** and not a dashboard (phase 11): it carries the same masthead with
|
|
#: no `aria-current` tab, and the account link in every dashboard's masthead is how you reach
|
|
#: it. Kept out of `PAGES` because the pins over that tuple assert tab structure — one current
|
|
#: tab, a fixed tab order — which this page deliberately does not have. `MARKUP_PAGES` is the
|
|
#: list for the checks that are about the bytes rather than the nav.
|
|
SETTINGS_PAGE = "/settings"
|
|
#: `/certs` is the second admin page (phase 15 task 05), kept out of `PAGES` for exactly the same
|
|
#: reason: it is not a tab. It is in `MARKUP_PAGES` and `PAGE_SCRIPTS`, so the CDN, `.sev-`,
|
|
#: flavour-text and orphan-`id` audits cover it like the six dashboards.
|
|
CERTS_PAGE = "/certs"
|
|
#: The name the `/gateway` pins use (`PAGE_SCRIPTS`, its page-shape parametrises) now that the
|
|
#: tuple above owns its tab.
|
|
GATEWAY_PAGE = "/gateway"
|
|
#: Every page that renders the masthead while signed in: the six dashboards and the two admin
|
|
#: pages. The nav pins loop this list, which is why eight pages carry six tabs and exactly two of
|
|
#: them carry nothing current.
|
|
MARKUP_PAGES = (*PAGES, SETTINGS_PAGE, CERTS_PAGE)
|
|
|
|
|
|
@contextmanager
|
|
def build_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, simulate: bool = False,
|
|
**overrides: Any) -> Iterator[TestClient]:
|
|
"""A TestClient whose lifespan runs, against a private DB and no real network.
|
|
|
|
The store is injected, so `create_app()` deliberately does not close it (production
|
|
owns its own); the caller here does, or every test leaks a SQLite handle and the
|
|
`--cov` log fills with ResourceWarnings that bury the warnings worth reading.
|
|
"""
|
|
if simulate:
|
|
monkeypatch.setenv("NUT_SIMULATE", "1")
|
|
else:
|
|
monkeypatch.delenv("NUT_SIMULATE", raising=False)
|
|
db = str(tmp_path / "api.sqlite3")
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2, **overrides)
|
|
store = SampleStore(db)
|
|
client = TestClient(create_app(settings, store))
|
|
client.__enter__()
|
|
try:
|
|
sign_in(client)
|
|
yield client
|
|
finally:
|
|
client.__exit__(None, None, None)
|
|
store.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
|
with build_client(tmp_path, monkeypatch) as test_client:
|
|
yield test_client
|
|
|
|
|
|
@pytest.fixture
|
|
def ups_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
|
"""NUT on the labelled simulator, actions allowed — the dev configuration."""
|
|
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
|
|
nut_allow_actions=True) as c:
|
|
yield c
|
|
|
|
|
|
def wait_for_source_error(client: TestClient, key: str, fragment: str,
|
|
*, timeout: float = 15.0) -> str:
|
|
"""Wait for one poll loop to record an error saying `fragment`, and hand back the sentence.
|
|
|
|
The poll loops run in the app's event loop, so an assertion about what a source said has to
|
|
wait for a poll rather than read the state at import time. The failure names the last error
|
|
seen, because "it never said that" and "it said something else entirely" are different bugs.
|
|
"""
|
|
deadline = time.time() + timeout
|
|
error = ""
|
|
while time.time() < deadline:
|
|
rows = {row["key"]: row for row in client.get("/api/health").json()["sources"]}
|
|
error = str((rows.get(key) or {}).get("error") or "")
|
|
if fragment in error:
|
|
return error
|
|
time.sleep(0.05)
|
|
pytest.fail(f"{key} never reported {fragment!r} (last error: {error!r})")
|
|
|
|
|
|
def wait_for_payload(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
response = client.get(path)
|
|
if response.status_code == 200:
|
|
return response.json()
|
|
time.sleep(0.1)
|
|
pytest.fail(f"{path} never became available")
|
|
|
|
|
|
def failing_host_payload(scrape: Any, agent_snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
"""Two scrapes of the real driveripper capture, nudged onto known sides of the table.
|
|
|
|
Driven from the fixture rather than hand-written so a schema drift breaks here first.
|
|
"""
|
|
def nudge(snap: dict[str, Any]) -> None:
|
|
snap["memory"]["used_bytes"] = snap["memory"]["total_bytes"] * 0.95 # mem.used_pct -> fail
|
|
snap["cpu_temperature_celsius"] = 91.0 # cpu.temp_c -> fail
|
|
for fs in snap["filesystems"]:
|
|
fs["used_pct"] = 93.0 # disk -> fail
|
|
|
|
return scrape(agent_snapshot, cpu_busy=10.0, mutate=nudge)
|
|
|
|
|
|
@pytest.fixture
|
|
def data_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
agent_snapshot: dict[str, Any], host_scrape: Any) -> Iterator[TestClient]:
|
|
"""Overview-shaped app: one host payload in the hub plus the simulated UPSes.
|
|
|
|
The host job points at a dead port, so a poll can never overwrite the injected payload
|
|
(a failed poll keeps the last good one) — the numbers below are ours, not the fleet's.
|
|
"""
|
|
monkeypatch.setenv("NUT_SIMULATE", "1")
|
|
db = str(tmp_path / "summary.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, nut_host="127.0.0.1",
|
|
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
|
test_client = TestClient(app)
|
|
test_client.__enter__()
|
|
try:
|
|
sign_in(test_client)
|
|
app.state.ctx.hub.jobs["host:driveripper"].state.payload = failing_host_payload(
|
|
host_scrape, agent_snapshot)
|
|
wait_for_payload(test_client, "/api/ups/snapshot")
|
|
yield test_client
|
|
finally:
|
|
test_client.__exit__(None, None, None)
|
|
store.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def healthy_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
agent_snapshot: dict[str, Any],
|
|
host_scrape: Any) -> Iterator[TestClient]:
|
|
"""The all-clear app: one host answering on both of its jobs, nothing past a threshold.
|
|
|
|
The job's collector is swapped for one that hands back the injected payload, so the
|
|
health record says `ok` truthfully instead of being overridden behind the poller — and
|
|
that has to happen for the `disks` job too, which polls the same dead port: a host is
|
|
either answering or it is not, and a fixture that claims all-clear cannot leave one of
|
|
its two readers failing. Each reader gets **its own** projection of the same capture
|
|
(`parse_disks_snapshot` for the drive page), because a `disks` job holding a `system`
|
|
payload would make `/api/summary`'s drive block answer from the wrong shape and read as
|
|
a passing test that proved nothing.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
|
|
from dashboards.poller import Result # noqa: PLC0415 - only this fixture needs it
|
|
|
|
payload = host_scrape(agent_snapshot, cpu_busy=10.0)
|
|
disks_payload = parse_disks_snapshot("driveripper", agent_snapshot,
|
|
DisksCollector()).payload
|
|
|
|
async def collect() -> Result:
|
|
return Result(payload=payload, samples=[])
|
|
|
|
async def collect_disks() -> Result:
|
|
return Result(payload=disks_payload, samples=[])
|
|
|
|
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
|
|
job = hub.jobs["host:driveripper"]
|
|
disks_job = hub.jobs["disks:driveripper"]
|
|
job.fn = collect
|
|
disks_job.fn = collect_disks
|
|
# let the poll already in flight against the dead port land first (it fails in
|
|
# microseconds), then take the health record over: the alerts must describe a host
|
|
# that is answering, not the half-second of startup where it was not
|
|
deadline = time.time() + 2.0
|
|
while time.time() < deadline and job.state.attempts == 0:
|
|
time.sleep(0.02)
|
|
time.sleep(0.05)
|
|
for reader in hub.jobs.values():
|
|
if reader.dashboard not in ("system", "disks"):
|
|
continue
|
|
reader.state.payload = disks_payload if reader is disks_job else payload
|
|
reader.state.ok = True
|
|
reader.state.error = None
|
|
reader.state.consecutive_failures = 0
|
|
reader.state.last_ok = time.time()
|
|
yield test_client
|
|
|
|
|
|
# ---- severity travels with the value --------------------------------------- #
|
|
|
|
def test_summary_carries_the_verdict_for_every_column_the_page_colours(data_client) -> None:
|
|
body = data_client.get("/api/summary").json()
|
|
row = body["system"]["hosts"][0]
|
|
assert row["severity"] == {"cpu_usage_pct": "ok", "mem_used_pct": "fail",
|
|
"disk_worst_used_pct": "fail", "temp_c": "fail"}
|
|
# the fleet card is judged by the same table, so amber means the same thing at both
|
|
# sizes — and no GPU verdict, at fleet size or host size (phase 06: busy is the job)
|
|
assert body["system"]["fleet"]["severity"] == {
|
|
"cpu_usage_pct_avg": "ok", "memory_used_pct": "fail",
|
|
"disk_worst_used_pct": "fail",
|
|
}
|
|
for ups in body["ups"]["ups"]:
|
|
assert set(ups["severity"]) == {"charge_pct", "load_pct", "runtime_seconds"}
|
|
assert ups["severity"]["charge_pct"] == classify("ups.charge_pct", ups["charge_pct"])
|
|
assert ups["severity"]["charge_pct"] == "ok", "the simulator starts at float charge"
|
|
|
|
|
|
def test_summary_shape_did_not_shrink(data_client) -> None:
|
|
"""Additive-only anchor: /api/summary gained `severity` and lost nothing.
|
|
|
|
The overview, the live tests, and any external consumer read the flat keys; this test is
|
|
the tripwire for a well-meaning refactor that "tidies" them away.
|
|
"""
|
|
body = data_client.get("/api/summary").json()
|
|
fleet_keys = {"hosts_total", "hosts_live", "cpu_cores", "cpu_usage_pct_avg",
|
|
"memory_total_bytes", "memory_used_bytes", "memory_used_pct",
|
|
"disk_worst_used_pct", "load1_total", "net_rx_bytes_per_s",
|
|
"net_tx_bytes_per_s", "gpu_count", "gpu_busy_pct_avg", "power_estimate_w"}
|
|
host_keys = {"name", "os", "cpu_usage_pct", "load1", "cores", "mem_used_pct",
|
|
"disk_worst_used_pct", "gpu_busy_pct", "temp_c", "uptime_seconds"}
|
|
ups_keys = {"name", "overall", "status", "charge_pct", "load_pct", "runtime_seconds"}
|
|
assert fleet_keys <= set(body["system"]["fleet"]), fleet_keys - set(body["system"]["fleet"])
|
|
assert host_keys <= set(body["system"]["hosts"][0]), host_keys - set(body["system"]["hosts"][0])
|
|
assert ups_keys <= set(body["ups"]["ups"][0]), ups_keys - set(body["ups"]["ups"][0])
|
|
# `disks` joined in phase 09 and `gateway` in phase 29, and changed nothing else: the set
|
|
# grows, never shrinks
|
|
assert set(body) == {"ts", "system", "disks", "gateway", "llm", "ups", "recent_events",
|
|
"alerts"}
|
|
# ...and the new keys are there beside the old ones, not instead of them
|
|
assert "severity" in body["system"]["fleet"]
|
|
assert "severity" in body["system"]["hosts"][0]
|
|
assert "severity" in body["ups"]["ups"][0]
|
|
|
|
|
|
# `/` paints two panels; the payload is still the whole-fleet contract. Every path here is
|
|
# read by something that is not the overview page: the live suite, an external probe, a
|
|
# notifier later. Deleting a card must never delete a key.
|
|
#: `/gateway`'s entries are the eleven fields phase 30 task 02 reshaped the block into plus the
|
|
#: `warnings` tally task 03 joined, listed rather than spot-checked because two consumers read it: a
|
|
#: notifier walking the totals, and the overview's one line (`page-index.js`'s `GATEWAY_FIELDS` +
|
|
#: `GATEWAY_WARNING_FIELD` + `href`), which is pinned against this same document by
|
|
#: `test_the_overview_line_reads_the_gateway_block_it_claims`. The pair of **row counts**
|
|
#: (`listeners`, `connections` — what the two configs declare) sits beside the pair of **live sums**
|
|
#: (`live_in`, `live_out` — what the socket table answered) because a gateway can have thirty
|
|
#: declared routes and no measurement of any of them, and `warnings` belongs with the row counts:
|
|
#: a config fact, so `0` there is the truth and only `null` is unknown.
|
|
SUMMARY_CONTRACT = ("system.enabled", "system.fleet", "system.hosts",
|
|
"disks.enabled", "disks.fleet", "disks.hosts",
|
|
"gateway.configured", "gateway.host", "gateway.href", "gateway.supported",
|
|
"gateway.unreachable", "gateway.listeners", "gateway.connections",
|
|
"gateway.live_in", "gateway.live_out", "gateway.map_targets",
|
|
"gateway.warnings", "gateway.states",
|
|
"llm.engines", "ups.ups", "recent_events", "alerts")
|
|
|
|
|
|
def dig(body: dict[str, Any], path: str) -> Any:
|
|
"""`body["a"]["b"]` that names the missing path when it is not there."""
|
|
node: Any = body
|
|
for part in path.split("."):
|
|
assert isinstance(node, dict) and part in node, f"/api/summary lost `{path}`"
|
|
node = node[part]
|
|
return node
|
|
|
|
|
|
@pytest.mark.parametrize("fixture", ["client", "data_client"])
|
|
def test_summary_serves_every_path_another_consumer_reads(request, fixture: str) -> None:
|
|
"""The keys survive the overview cleanup whether or not anything is configured.
|
|
|
|
Run twice on purpose: `client` has no targets at all, so it proves the keys are present
|
|
as `None`/`[]` rather than omitted when a source is unconfigured (invariant 3 is about
|
|
the payload shape as much as about colour); `data_client` proves the same paths carry
|
|
the fleet once there is one.
|
|
"""
|
|
test_client = request.getfixturevalue(fixture)
|
|
body = test_client.get("/api/summary").json()
|
|
populated = fixture == "data_client"
|
|
for path in SUMMARY_CONTRACT:
|
|
dig(body, path) # presence is the assertion; a missing path raises with its name
|
|
assert dig(body, "system.enabled") is populated
|
|
assert (dig(body, "system.fleet") is not None) is populated
|
|
names = {host["name"] for host in dig(body, "system.hosts")}
|
|
assert names == ({"driveripper"} if populated else set())
|
|
# the drive-health block is configured exactly when the agents it reads are, and lists the
|
|
# same hosts: a `disks` job exists for every `HOST_TARGETS` entry and for no other reason
|
|
assert dig(body, "disks.enabled") is populated
|
|
assert (dig(body, "disks.fleet") is not None) is populated
|
|
assert {host["name"] for host in dig(body, "disks.hosts")} == names
|
|
batteries = {item["name"] for item in dig(body, "ups.ups")}
|
|
assert batteries == ({"apc", "cyberpower"} if populated else set())
|
|
# the ingress block is present and empty in **both** fixtures: it needs a *name* among the
|
|
# targets (`GATEWAY_HOST`, default `gateway`), and `data_client` monitors `driveripper`. An
|
|
# unconfigured source is a block of nulls with no states, not a missing key and not a red row
|
|
# — and the nulls are nulls rather than `0`, because "no job ran" is not "no connections".
|
|
assert dig(body, "gateway.configured") is False
|
|
assert dig(body, "gateway.states") == []
|
|
assert (dig(body, "gateway.listeners"), dig(body, "gateway.connections")) == (None, None)
|
|
assert (dig(body, "gateway.live_in"), dig(body, "gateway.live_out")) == (None, None)
|
|
assert dig(body, "llm.engines") == [], "neither fixture configures an engine"
|
|
assert isinstance(dig(body, "recent_events"), list)
|
|
assert isinstance(dig(body, "alerts"), list)
|
|
# and the payload as a whole still carries every top-level section it always did
|
|
assert set(body) >= {"ts", "system", "llm", "ups", "recent_events", "alerts"}
|
|
|
|
|
|
# ---- alerts: the landing page's answer to "what is wrong" ------------------- #
|
|
|
|
def test_summary_is_empty_of_alerts_when_everything_is_healthy(healthy_client) -> None:
|
|
"""`[]`, not a green banner: an empty list is the clearest possible all-clear."""
|
|
body = healthy_client.get("/api/summary").json()
|
|
assert body["alerts"] == []
|
|
assert body["system"]["fleet"]["hosts_live"] == 1
|
|
|
|
|
|
def test_summary_raises_one_alert_for_the_host_that_stopped_answering(data_client) -> None:
|
|
"""One dead host is one line naming it, with the link to the page that explains it."""
|
|
body = data_client.get("/api/summary").json()
|
|
down = [item for item in body["alerts"] if item["source"] == "driveripper"]
|
|
assert len(down) == 1, "its stale payload must not add amber lines on top of the red one"
|
|
assert down[0]["severity"] == "fail"
|
|
assert down[0]["href"] == "/system"
|
|
assert "unreachable" in down[0]["message"]
|
|
|
|
|
|
def test_a_host_that_has_never_answered_is_still_a_row_in_the_fleet(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A dead agent is a red row in the fleet, not a fleet that quietly got smaller.
|
|
|
|
`/api/system/snapshot` already stubs a job with no payload; `/api/summary` has to agree
|
|
with it, or a target that never answered since startup leaves the table at "2/2 hosts"
|
|
while the panel above says a third box is down (`AGENTS.md` invariants 3 and 4).
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
|
|
body = test_client.get("/api/summary").json()
|
|
rows = body["system"]["hosts"]
|
|
assert [row["name"] for row in rows] == ["driveripper"]
|
|
assert rows[0]["unreachable"] is True
|
|
assert rows[0]["cpu_usage_pct"] is None, "n/a, never a 0.0 nobody measured"
|
|
assert body["system"]["fleet"]["hosts_total"] == 1
|
|
assert body["system"]["fleet"]["hosts_live"] == 0
|
|
# and the alert list stays exactly one line for it, not a row plus a stale payload
|
|
assert [item["source"] for item in body["alerts"]] == ["driveripper"]
|
|
|
|
|
|
def test_summary_alerts_are_worst_first_and_carry_the_contract(data_client) -> None:
|
|
body = data_client.get("/api/summary").json()
|
|
alerts_list = body["alerts"]
|
|
assert alerts_list, "a dead host plus simulated UPS data cannot read as all-clear"
|
|
for item in alerts_list:
|
|
assert set(item) == {"id", "severity", "source", "message", "href", "at"}
|
|
assert item["severity"] in ("warn", "fail")
|
|
ranks = [0 if item["severity"] == "fail" else 1 for item in alerts_list]
|
|
assert ranks == sorted(ranks), "fails before warns"
|
|
assert any("simulat" in item["message"].lower() and item["href"] == "/ups"
|
|
for item in alerts_list), "NUT_SIMULATE must be said out loud on the landing page"
|
|
|
|
|
|
def test_summary_reuses_the_alert_builder_verbatim(data_client) -> None:
|
|
"""The endpoint is a thin call: what `alerts.build` says over the same state is the body."""
|
|
body = data_client.get("/api/summary").json()
|
|
hub = data_client.app.state.ctx.hub # type: ignore[attr-defined]
|
|
settings = data_client.app.state.ctx.settings # type: ignore[attr-defined]
|
|
hosts = [job.state.payload for job in hub.jobs.values()
|
|
if job.dashboard == "system" and job.state.payload]
|
|
ups_payload = hub.jobs["nut"].state.payload
|
|
expected = alerts.build(settings, hub.states(), hosts, [], ups_payload, [])
|
|
assert [item["id"] for item in body["alerts"]] == [item["id"] for item in expected]
|
|
|
|
|
|
def test_snapshot_payloads_carry_their_own_severity_sections(data_client) -> None:
|
|
"""/api/system/snapshot and /api/ups/snapshot ship the same verdicts the summary reuses."""
|
|
system = data_client.get("/api/system/snapshot").json()
|
|
host = system["hosts"][0]
|
|
assert set(host["cpu"]["severity"]) == {"usage_pct", "temp_c"}
|
|
assert set(host["memory"]["severity"]) == {"used_pct"}
|
|
assert set(host["disks"]["severity"]) == {"worst_used_pct"}
|
|
assert "severity" not in host["gpus"][0], (
|
|
"a GPU verdict came back on /api/system/snapshot — phase 06 removed it"
|
|
)
|
|
ups = data_client.get("/api/ups/snapshot").json()
|
|
for card in ups["ups"]:
|
|
assert set(card["severity"]) == {"charge_pct", "load_pct", "battery_runtime"}
|
|
# numbers keep their own shape; severity is a sibling, never a replacement
|
|
assert isinstance(card["numbers"], dict) and "charge_pct" in card["numbers"]
|
|
|
|
|
|
# ---- the self-test verdict on the two surfaces that were wrong (phase 12) --- #
|
|
|
|
#: The `ups.test.result` sentences as the hardware answers them. `Done and passed` is the
|
|
#: CyberPower's verbatim word from the 2026-10-19 capture (`tests/test_nut.py
|
|
#: ::LIVE_SELF_TEST_WORDS`); `FAILED` is what a driver says when a test ran and did not pass,
|
|
#: and is the reason the check exists at all. `tests/test_nut.py` pins the *word map*; what is
|
|
#: pinned here is the surface `TODO.md:3` names — a warning in the overview's Warnings panel —
|
|
#: because a verdict that grades correctly inside `assess()` and still reaches the alert feed
|
|
#: as a warning is the same bug wearing a different hat.
|
|
SELF_TEST_PASS_WORD = "Done and passed"
|
|
SELF_TEST_FAIL_WORD = "FAILED"
|
|
|
|
#: How `/api/summary` says the check out loud (`alerts._ups_alerts`): `"<check name>: <detail>"`.
|
|
SELF_TEST_ALERT_TEXT = "Last self-test"
|
|
|
|
|
|
def set_self_test_verdict(payload: dict[str, Any], status: str, detail: str) -> None:
|
|
"""Rewrite every card's `Last self-test` check in the hub's own UPS payload.
|
|
|
|
Mutating the payload the job actually serves (instead of hand-building a fake one) is what
|
|
makes this an end-to-end pin: `/api/ups/snapshot` and `alerts.build()` then read the *same*
|
|
object, so the test cannot pass by having the two surfaces disagree about the verdict.
|
|
"""
|
|
graded = 0
|
|
for card in payload["ups"]:
|
|
for check in card["health"]["checks"]:
|
|
if check["name"] == SELF_TEST_ALERT_TEXT:
|
|
check["status"] = status
|
|
check["detail"] = detail
|
|
graded += 1
|
|
assert graded, "no `Last self-test` check in the UPS payload — nothing was tested"
|
|
|
|
|
|
@contextmanager
|
|
def self_test_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
) -> Iterator[tuple[TestClient, dict[str, Any]]]:
|
|
"""A UPS app whose served payload cannot be replaced by a poll mid-assertion.
|
|
|
|
The simulated UPSes answer `PASSED`, so the pass direction would survive a race with the
|
|
poll loop and the *fail* direction would not — a test that flakes on a poll is a test of
|
|
timing. Hence `healthy_client`'s trick for the `nut` job: the collector hands back the very
|
|
dict this harness holds, so the health record says `ok` truthfully (invariant 4) and the
|
|
payload stays ours. The interval is shortened for the same reason — waiting a real
|
|
`NUT_INTERVAL_SECONDS` per test would only make the suite slower at proving the same thing.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
|
|
nut_interval=0.5) as test_client:
|
|
from dashboards.poller import Result # noqa: PLC0415 - only this harness needs it
|
|
|
|
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
|
|
job = hub.jobs["nut"]
|
|
wait_for_payload(test_client, "/api/ups/snapshot")
|
|
payload = job.state.payload
|
|
|
|
async def collect() -> Result:
|
|
return Result(payload=payload, samples=[])
|
|
|
|
job.fn = collect
|
|
# let one poll of *our* collector land before the caller mutates the payload, so the
|
|
# health record and the served bytes are both demonstrably ours
|
|
baseline = job.state.attempts
|
|
deadline = time.time() + 5.0
|
|
while time.time() < deadline and job.state.attempts <= baseline:
|
|
time.sleep(0.02)
|
|
assert job.state.payload is payload and job.state.ok, (
|
|
"the nut job is not serving the payload under test — the pin below would be fiction"
|
|
)
|
|
yield test_client, payload
|
|
|
|
|
|
def self_test_alerts(alerts_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return [item for item in alerts_list
|
|
if SELF_TEST_ALERT_TEXT.lower() in item["message"].lower()
|
|
or SELF_TEST_ALERT_TEXT.lower() in item["id"].lower()]
|
|
|
|
|
|
def test_a_passed_self_test_is_quiet_on_the_overview_and_ok_on_the_ups_page(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The TODO's sentence, end to end: `Done and passed` is not a warning anywhere.
|
|
|
|
Both halves of the fix are asserted on one payload: `/api/ups/snapshot` carries the `ok`
|
|
entry the `/ups` check list renders verbatim, and `/api/summary` — the only call the
|
|
overview makes — carries **no** row naming it. The simulated card still contributes its
|
|
`NUT_SIMULATE` line, so the list is not empty and this cannot be a vacuous pass.
|
|
"""
|
|
with self_test_app(tmp_path, monkeypatch) as (test_client, payload):
|
|
set_self_test_verdict(payload, "ok", SELF_TEST_PASS_WORD)
|
|
cards = wait_for_payload(test_client, "/api/ups/snapshot")["ups"]
|
|
for card in cards:
|
|
check = next(c for c in card["health"]["checks"]
|
|
if c["name"] == SELF_TEST_ALERT_TEXT)
|
|
assert check["status"] == "ok", f"{card['name']} reports a pass as {check['status']}"
|
|
assert check["detail"] == SELF_TEST_PASS_WORD, "the driver's word is the useful half"
|
|
rows = self_test_alerts(test_client.get("/api/summary").json()["alerts"])
|
|
assert rows == [], f"a passed battery test is on the overview as {rows}"
|
|
assert any(item["source"] == "ups" for item in
|
|
test_client.get("/api/summary").json()["alerts"]), (
|
|
"the simulated-UPS line disappeared, so this assertion has nothing left to be quiet about"
|
|
)
|
|
|
|
|
|
def test_a_never_tested_battery_is_not_an_alert_either(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The APC's factory `No test initiated` reaches the overview as silence, not amber.
|
|
|
|
This is the state the live hardware is actually in on one of the two UPSes, so the
|
|
screenshot proves the absence of a warning for a *never-run* test while the payload pin
|
|
above proves it for a passed one. `unknown` is not a verdict an operator can act on, and
|
|
`alerts._RANK` admits only `fail` and `warn`.
|
|
"""
|
|
with self_test_app(tmp_path, monkeypatch) as (test_client, payload):
|
|
set_self_test_verdict(payload, "unknown", nut.NEVER_TESTED_DETAIL)
|
|
cards = wait_for_payload(test_client, "/api/ups/snapshot")["ups"]
|
|
assert all(next(c for c in card["health"]["checks"]
|
|
if c["name"] == SELF_TEST_ALERT_TEXT)["status"] == "unknown"
|
|
for card in cards)
|
|
assert self_test_alerts(test_client.get("/api/summary").json()["alerts"]) == []
|
|
|
|
|
|
def test_a_failed_self_test_still_reaches_the_overview(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The check keeps its reason for existing: a test that failed is a red line, once per UPS.
|
|
|
|
Without this direction the absence test above is satisfiable by deleting the check, which
|
|
is the failure mode the phase's completion criterion calls out.
|
|
"""
|
|
with self_test_app(tmp_path, monkeypatch) as (test_client, payload):
|
|
set_self_test_verdict(payload, "fail", SELF_TEST_FAIL_WORD)
|
|
names = {card["name"] for card in
|
|
wait_for_payload(test_client, "/api/ups/snapshot")["ups"]}
|
|
assert names, "no UPS card to fail"
|
|
rows = self_test_alerts(test_client.get("/api/summary").json()["alerts"])
|
|
assert {row["source"] for row in rows} == names
|
|
for row in rows:
|
|
assert row["severity"] == "fail", f"a failed battery test reads {row['severity']}"
|
|
assert row["href"] == "/ups", "the alert must link to the card that names the UPS"
|
|
assert SELF_TEST_ALERT_TEXT in row["message"] and SELF_TEST_FAIL_WORD in row["message"]
|
|
assert row["message"].count(SELF_TEST_ALERT_TEXT) == 1, (
|
|
"the row names the check twice — see alerts._clean_error")
|
|
assert all(row["id"] == f"{row['source']}:health:{SELF_TEST_ALERT_TEXT}" for row in rows)
|
|
|
|
|
|
def test_the_ups_check_list_needs_no_new_markup_for_a_passed_verdict(client) -> None:
|
|
"""Task 03 expected no page change, and this pins *why* there is none.
|
|
|
|
`page-ups.js` puts `check.status` straight on the `<li>`, and `dash.css` colours only the
|
|
`.mark` glyph from the shared `--ok`/`--warn`/`--fail` tokens while the check's name and
|
|
detail stay `--text`/`--text-muted` — so an `ok` row is quiet ink with a tick, and a verdict
|
|
fixed in `assess()` is a verdict rendered. Nothing on either asset names the self-test
|
|
check, which is the point: a special case added here would be a second opinion about the
|
|
driver's word, and the API already made it.
|
|
"""
|
|
js = _code(client.get("/static/page-ups.js").text)
|
|
assert '<li class="${check.status}">' in js, "the row no longer wears the payload verdict as its class"
|
|
for verdict in ("ok", "warn", "fail", "unknown"):
|
|
assert f"{verdict}: '" in js[js.index("const MARKS"):], f"MARKS lost the {verdict} glyph"
|
|
rows = js[js.index("const health = item.health"):js.index("const commands = item.commands")]
|
|
assert "self-test" not in rows.lower(), "the check list grew an opinion about one check by name"
|
|
css = _code(_css(client))
|
|
ok_rule = re.search(r"ul\.checks li\.ok \.mark \{([^}]*)\}", css)
|
|
assert ok_rule and "var(--ok)" in ok_rule.group(1), "the ok tick is not the shared token"
|
|
assert "ul.checks li.ok {" not in css, "the whole ok row is coloured — a pass is not a banner"
|
|
assert "self-test" not in css.lower(), "the stylesheet special-cases one check by name"
|
|
|
|
|
|
# ---- unconfigured: say so, and do not dial -------------------------------- #
|
|
|
|
def test_config_reports_every_source_as_off(client) -> None:
|
|
body = client.get("/api/config").json()
|
|
assert body["system"]["enabled"] is False
|
|
assert body["system"]["targets"] == []
|
|
assert body["disks"] == {"enabled": False, "interval_seconds": 300.0,
|
|
"timeout_seconds": 30.0}, (
|
|
"the drive-health page is configured exactly when the host agents are")
|
|
assert body["gateway"] == {
|
|
"enabled": False,
|
|
# the default name is still printed when nothing matches it: "no host called `gateway` is
|
|
# being dialled" is the sentence, and the name is the half the operator has to change
|
|
"host": "gateway",
|
|
"url": None,
|
|
"interval_seconds": 30.0,
|
|
"timeout_seconds": 10.0,
|
|
# the write's own flag, off by default (invariant 1's second exception): the page
|
|
# renders the switch's honest arm from this rather than assuming it
|
|
"writes_enabled": False,
|
|
}, "the ingress is configured by a name in HOST_TARGETS and by nothing else"
|
|
assert body["llm"]["gufo"]["enabled"] is False
|
|
assert body["llm"]["vllm"]["enabled"] is False
|
|
assert body["ups"]["enabled"] is False
|
|
assert body["ups"]["actions_enabled"] is False
|
|
assert body["ranges"], "the page builds its window buttons from this list"
|
|
|
|
|
|
def test_health_with_nothing_configured_is_not_a_failure(client) -> None:
|
|
"""Nothing configured is 'empty', not 'broken' — and the page must not go red."""
|
|
body = client.get("/api/health").json()
|
|
assert body["status"] in ("ok", "empty")
|
|
assert body["ok"] is True
|
|
assert body["sources"] == []
|
|
|
|
|
|
def test_the_disk_source_has_its_own_health_record_per_host(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`disks` is a source of its own, not a field of `system` (phase 09).
|
|
|
|
Its own record is what gives the page its own dot and its own staleness, and it is
|
|
also what makes `alerts.py` need a rule: two jobs read the same agent, so one dead
|
|
host must still be one line on the overview (see `test_summary_raises_one_alert...`).
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
|
|
sources = {row["key"]: row for row in test_client.get("/api/health").json()["sources"]}
|
|
assert "disks:driveripper" in sources, "no separate record, no separate dot"
|
|
assert sources["disks:driveripper"]["dashboard"] == "disks"
|
|
assert sources["disks:driveripper"]["label"] == "driveripper"
|
|
assert sources["disks:driveripper"]["configured"] is True
|
|
assert sources["host:driveripper"]["dashboard"] == "system", (
|
|
"the system job is still its own source: one slow SMART poll must not "
|
|
"decide whether the host looks alive")
|
|
|
|
|
|
def test_no_targets_means_no_disk_job_that_could_look_broken(client) -> None:
|
|
"""No host agents, no `disks` task at all — so nothing red for an unconfigured page."""
|
|
assert client.get("/api/health").json()["sources"] == []
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/api/system/snapshot", "/api/llm/snapshot",
|
|
"/api/ups/snapshot", "/api/ups/commands"])
|
|
def test_unconfigured_source_is_404_configured_false(client, path) -> None:
|
|
response = client.get(path)
|
|
assert response.status_code == 404
|
|
assert response.json()["configured"] is False
|
|
assert "detail" in response.json()
|
|
|
|
|
|
def test_summary_still_answers_with_no_sources(client) -> None:
|
|
body = client.get("/api/summary").json()
|
|
assert body["system"]["enabled"] is False
|
|
assert body["system"]["fleet"] is None
|
|
assert body["llm"]["enabled"] is False
|
|
assert body["ups"]["enabled"] is False
|
|
|
|
|
|
def test_unknown_route_is_json_not_html(client) -> None:
|
|
response = client.get("/api/nope")
|
|
assert response.status_code == 404
|
|
assert response.headers["content-type"].startswith("application/json")
|
|
assert "no such route" in response.json()["detail"]
|
|
|
|
def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Path) -> None:
|
|
"""`collectors.UpstreamError` is the one error type a route may raise to say "upstream".
|
|
|
|
The handler turns it into `{ok: false, detail}` with the collector's status (502, or 504
|
|
for a timeout) instead of a 500 traceback, and the message is the collector's already
|
|
redacted sentence. Nothing dials here: the error is raised by the route under test.
|
|
"""
|
|
db = str(tmp_path / "upstream-error.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
|
|
@app.get("/api/_boom")
|
|
async def boom() -> None:
|
|
raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504)
|
|
|
|
with TestClient(app) as test_client:
|
|
sign_in(test_client)
|
|
response = test_client.get("/api/_boom")
|
|
assert response.status_code == 504, "a timeout stays a timeout, not a 500"
|
|
assert response.json() == {"ok": False,
|
|
"detail": "driveripper: http://127.0.0.1:9120/snapshot "
|
|
"timed out"}
|
|
assert "no-store" in response.headers["cache-control"]
|
|
store.close()
|
|
|
|
|
|
# ---- tokens --------------------------------------------------------------- #
|
|
|
|
def test_agent_tokens_never_reach_the_browser(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("NUT_SIMULATE", "")
|
|
targets = [Target(name="driveripper", url="http://driveripper:9120",
|
|
headers={"Authorization": "Bearer SEKRIT-TOKEN"})]
|
|
db = str(tmp_path / "tokens.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=tuple(targets)), store)
|
|
with TestClient(app) as client:
|
|
sign_in(client)
|
|
body = client.get("/api/config")
|
|
assert "SEKRIT-TOKEN" not in body.text
|
|
target = body.json()["system"]["targets"][0]
|
|
assert target["name"] == "driveripper"
|
|
assert "SEKRIT" not in target["url"] and "@" not in target["url"]
|
|
# and a host that never answered stays in the list, marked, with the fleet card intact
|
|
snapshot = wait_for_state(client, "/api/system/snapshot")
|
|
assert snapshot["hosts"][0]["name"] == "driveripper"
|
|
assert snapshot["hosts"][0].get("unreachable") is True
|
|
assert snapshot["fleet"]["hosts_total"] == 1
|
|
assert snapshot["fleet"]["hosts_live"] == 0
|
|
assert snapshot["fleet"]["memory_total_bytes"] is None
|
|
store.close()
|
|
|
|
|
|
def test_a_dead_host_never_writes_its_token_into_the_feed(tmp_path: Path) -> None:
|
|
"""The second path a credential can take to a browser: the failure message.
|
|
|
|
`parse_targets` moves a userinfo token out of the URL and into a bearer header, so the
|
|
documented `HOST_TARGETS` path never puts one in `Target.url` — this is the backstop for
|
|
any `Target` that does (a hand-built one, a future parser), and the message path is where
|
|
it would matter: the poller keeps an `UpstreamError` string in the health record and logs
|
|
it as an event, so a userinfo-bearing URL there would sit in SQLite and be served on
|
|
`/api/events` forever (AGENTS.md invariant 6). Asserted on every endpoint a page reads,
|
|
not just `/api/config`.
|
|
"""
|
|
db = str(tmp_path / "dead-host-token.sqlite3")
|
|
store = SampleStore(db)
|
|
settings = Settings(
|
|
db_path=db,
|
|
admin_password=ADMIN_PASSWORD, pbkdf2_iterations=FAST_PBKDF2,
|
|
host_interval=0.05,
|
|
host_targets=(Target(name="driveripper", url="http://SEKRIT-AGENT-TOKEN@127.0.0.1:1"),),
|
|
)
|
|
with TestClient(create_app(settings, store)) as client:
|
|
sign_in(client)
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline:
|
|
health = client.get("/api/health").json()
|
|
if health["sources"] and health["sources"][0]["error"]:
|
|
break
|
|
time.sleep(0.05)
|
|
else:
|
|
pytest.fail("the dead host never recorded an error")
|
|
for path in ("/api/health", "/api/events", "/api/system/snapshot",
|
|
"/api/disks/snapshot", "/api/summary"):
|
|
body = client.get(path).text
|
|
assert "SEKRIT-AGENT-TOKEN" not in body, f"{path} leaked the agent token"
|
|
# and the outage is still legible — redaction is not the same as silence
|
|
assert "driveripper" in client.get("/api/health").text
|
|
assert "unreachable" in client.get("/api/events").text
|
|
# the `disks` job dials the same token-bearing URL on its own cadence, so its
|
|
# sentence is a second copy of the same risk: `fetch_snapshot` is the one place
|
|
# either dashboard builds a failure message, and it uses the redacted URL
|
|
disks_state = next(row for row in client.get("/api/health").json()["sources"]
|
|
if row["key"] == "disks:driveripper")
|
|
assert disks_state["error"] and "SEKRIT-AGENT-TOKEN" not in disks_state["error"]
|
|
assert "127.0.0.1:1/snapshot" in disks_state["error"], "the failure still names where"
|
|
store.close()
|
|
|
|
|
|
def test_a_revoked_agent_fails_both_of_its_sources_without_a_dial(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The chain end to end, wiring included: revoke it, and the next poll stops talking to it.
|
|
|
|
`collectors.agent_tls_map` is what `build_hub` hands the jobs, so this is the proof that a
|
|
revocation reaches a *running* app through its own `meta` table — no restart, and no second
|
|
process telling the first. The spy replaces `httpx.AsyncClient.get` on the class, so it
|
|
catches the shared client and the per-dial TLS client alike: what must not happen is any
|
|
request at all, because a dial that then gets refused still put a socket on a host the
|
|
operator struck out.
|
|
"""
|
|
db = str(tmp_path / "revoked-agent.sqlite3")
|
|
store = SampleStore(db)
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_interval=0.05, disks_interval=0.05,
|
|
host_targets=(Target(name="deskwork", url="https://127.0.0.1:1"),))
|
|
with TestClient(create_app(settings, store)) as client:
|
|
sign_in(client)
|
|
# Before the revoke the same dead host fails as an unreachable upstream. That sentence is
|
|
# the control: it is what a dial produces, so the one after it has to mean something else.
|
|
wait_for_source_error(client, "host:deskwork", "unreachable")
|
|
state = certs.issue_agent_cert(store, certs.ensure_certs(store), "deskwork",
|
|
["127.0.0.1"])
|
|
certs.revoke(store, state, "deskwork")
|
|
|
|
calls: list[str] = []
|
|
|
|
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
calls.append(str(url))
|
|
raise AssertionError(f"a revoked agent was dialled at {url}")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
|
|
for key in ("host:deskwork", "disks:deskwork"):
|
|
error = wait_for_source_error(client, key, "agent certificate revoked")
|
|
assert "unreachable" not in error, "the refusal replaced the dial's sentence"
|
|
assert calls == [], "the gate is in front of the network, not behind it"
|
|
# and the overview says the operator revoked it rather than that the box went down
|
|
messages = [row["message"] for row in client.get("/api/summary").json()["alerts"]]
|
|
assert any("certificate revoked" in text for text in messages), messages
|
|
store.close()
|
|
|
|
|
|
def test_engine_urls_are_redacted_the_same_way_hosts_are(tmp_path: Path) -> None:
|
|
"""`GUFO_BASE_URL`/`VLLM_BASE_URL` go to the browser verbatim in `/api/config`, and unlike
|
|
`HOST_TARGETS` nothing parses a userinfo token out of them into a header — so a URL that
|
|
happens to carry credentials has to be redacted where it is served (invariant 6).
|
|
"""
|
|
db = str(tmp_path / "engine-redact.sqlite3")
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
gufo_url="http://SEKRIT-ENGINE-TOKEN@deskwork:8731",
|
|
vllm_url="http://driveripper:8000")
|
|
store = SampleStore(db)
|
|
with TestClient(create_app(settings, store)) as client:
|
|
sign_in(client)
|
|
body = client.get("/api/config")
|
|
assert "SEKRIT-ENGINE-TOKEN" not in body.text
|
|
llm = body.json()["llm"]
|
|
assert llm["gufo"]["base_url"] == "http://deskwork:8731"
|
|
assert llm["vllm"]["base_url"] == "http://driveripper:8000" # untouched when clean
|
|
store.close()
|
|
|
|
|
|
@pytest.mark.parametrize(("llm_interval", "rate_window"), [(2.0, 10.0), (4.0, 20.0)])
|
|
def test_build_hub_gives_each_source_the_estimator_its_own_cadence_fills(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
llm_interval: float, rate_window: float) -> None:
|
|
"""Two rate estimators, one per measuring cadence (phase 23, `TODO.md:8`).
|
|
|
|
The spy is the point: the numbers themselves are `config` defaults and a test on the default
|
|
would pass even if `build_hub` handed one estimator to both sources. So this asserts the two
|
|
**objects** the jobs receive, and does it at a non-default cadence pair to prove the LLM span
|
|
tracks `LLM_INTERVAL_SECONDS` rather than a second knob nobody configured — one 10 s window
|
|
smoothed a slot start across the five polls the operator was watching (the ramp), and a 2 s
|
|
window over a 10 s `host` poll would answer `n/a` every poll.
|
|
|
|
The payload-level twin of this pin is the `Δ<window>` label each rate cell carries: this is
|
|
the wiring, that is what the browser prints (phase 23 task 02 pins the labels).
|
|
"""
|
|
seen: dict[str, RateEstimator] = {}
|
|
real_host_jobs = app_module.host_jobs
|
|
real_llm_jobs = app_module.llm_jobs
|
|
|
|
def spy_host_jobs(targets: Any, http: Any, host_collector: Any, interval: float,
|
|
**kwargs: Any) -> list[Any]:
|
|
seen["host"] = host_collector.rates
|
|
return real_host_jobs(targets, http, host_collector, interval, **kwargs)
|
|
|
|
def spy_llm_jobs(cfg: Any, http: Any, rates: RateEstimator) -> list[Any]:
|
|
seen["llm"] = rates
|
|
return real_llm_jobs(cfg, http, rates)
|
|
|
|
monkeypatch.setattr(app_module, "host_jobs", spy_host_jobs)
|
|
monkeypatch.setattr(app_module, "llm_jobs", spy_llm_jobs)
|
|
|
|
db = str(tmp_path / "rate-windows.sqlite3")
|
|
store = SampleStore(db)
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("deskwork", DEAD_URL),),
|
|
gufo_url="http://deskwork:8731", vllm_url="http://driveripper:8000",
|
|
llm_interval=llm_interval, rate_window=rate_window)
|
|
# `build_hub` only wires jobs; nothing here dials, so the shared client is never started
|
|
# and there is no event loop to close one in.
|
|
hub, _nut_monitor = app_module.build_hub(settings, store, httpx.AsyncClient())
|
|
store.close()
|
|
|
|
assert sorted(seen) == ["host", "llm"], "a source stopped being handed an estimator"
|
|
assert sorted(hub.jobs) == ["disks:deskwork", "host:deskwork", "llm:gufo", "llm:vllm"]
|
|
assert seen["llm"] is not seen["host"], \
|
|
"one estimator for both cadences is the bug this phase exists to fix"
|
|
assert seen["llm"].span == pytest.approx(llm_interval), \
|
|
"the LLM window must track LLM_INTERVAL_SECONDS, not a second knob"
|
|
assert seen["host"].span == pytest.approx(rate_window), \
|
|
"the host rates keep RATE_WINDOW_SECONDS: a 2 s span over a 10 s poll is `n/a` forever"
|
|
# …and the smaller window still carries the production floor, so the phase-13 refusal of the
|
|
# poll loop's ~0.2 s catch-up gap is not a consequence of the wider span.
|
|
assert seen["llm"].min_window == MIN_WINDOW_SECONDS
|
|
assert seen["host"].min_window == MIN_WINDOW_SECONDS
|
|
assert seen["llm"].span > MIN_WINDOW_SECONDS, "the LLM window would refuse every poll"
|
|
|
|
|
|
def test_config_reports_the_window_the_llm_rates_are_actually_measured_over(
|
|
client: TestClient) -> None:
|
|
"""`/api/config`'s llm window is a measurement claim, so it is the LLM's own span.
|
|
|
|
It used to echo `RATE_WINDOW_SECONDS` for both sources, which was harmless while one estimator
|
|
served them all. Since phase 23 that same field would have advertised a 10 s window beside
|
|
rates measured over 2 s — a number the page can print and no poll ever used (rule 2).
|
|
"""
|
|
llm = client.get("/api/config").json()["llm"]
|
|
assert llm["rate_window_seconds"] == llm["interval_seconds"] == 2.0
|
|
|
|
|
|
# ---- the llm engine registry (phase 34 task 02) --------------------------- #
|
|
|
|
def test_config_serves_radiance_additively_beside_byte_identical_gufo_and_vllm(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The additive pin: `radiance` joins the llm block as a third key, and nothing about
|
|
`gufo`/`vllm` moves — same keys, same shape, same order — while an unset radiance is
|
|
present as `enabled: false` (a key that appeared only when configured would be the
|
|
two-engine world leaking back into the API). The redaction is the one rule:
|
|
`RADIANCE_API_KEY` is the documented way to authenticate, and a token written into
|
|
the URL instead is stripped before it is served (invariant 6), like the other two.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch) as plain:
|
|
before = plain.get("/api/config").json()["llm"]
|
|
with build_client(tmp_path, monkeypatch,
|
|
radiance_url="http://SEKRIT-RADIANCE-TOKEN@driveripper:8000",
|
|
radiance_api_key="radiance-key") as with_radiance:
|
|
after = with_radiance.get("/api/config").json()["llm"]
|
|
assert before["gufo"] == after["gufo"]
|
|
assert before["vllm"] == after["vllm"]
|
|
assert before["radiance"] == {"enabled": False, "base_url": "", "auth": False}
|
|
assert after["radiance"] == {"enabled": True, "base_url": "http://driveripper:8000",
|
|
"auth": True}
|
|
assert "SEKRIT-RADIANCE-TOKEN" not in json.dumps(after), "the URL token must not be served"
|
|
assert list(after) == ["gufo", "vllm", "radiance", "interval_seconds", "poll_ms",
|
|
"rate_window_seconds"], "the registry's stable order is the byte order"
|
|
|
|
|
|
def test_the_llm_404_gate_answers_from_the_registry_in_all_configurations(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""One engine configured — whichever of the three — is a configured source, and the
|
|
404 detail names all three variable names so the not-configured arm says the fix
|
|
for the whole world, not just the two it used to know."""
|
|
single: list[dict[str, Any]] = [{"gufo_url": "http://deskwork:8731"},
|
|
{"vllm_url": "http://vllm-box:8000"},
|
|
{"radiance_url": "http://driveripper:8000"}]
|
|
for overrides in single:
|
|
with build_client(tmp_path, monkeypatch, **overrides) as test_client:
|
|
response = test_client.get("/api/llm/snapshot")
|
|
assert response.status_code == 200, f"only {next(iter(overrides))} configured must not 404"
|
|
with build_client(tmp_path, monkeypatch) as test_client:
|
|
response = test_client.get("/api/llm/snapshot")
|
|
assert response.status_code == 404
|
|
body = response.json()
|
|
assert body["configured"] is False
|
|
for name in ("GUFO_BASE_URL", "VLLM_BASE_URL", "RADIANCE_BASE_URL"):
|
|
assert name in body["detail"], f"the not-configured arm must name {name}"
|
|
|
|
|
|
def test_llm_history_defaults_list_the_configured_engines_from_the_registry(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The defaults are the registry's configured `key`s in registry order — `key` is the
|
|
history DB's host key samples are written under, not the display name — and an
|
|
unconfigured engine is not among them, so it is never requested and never dialled.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
gufo_url="http://deskwork:8731",
|
|
radiance_url="http://driveripper:8000") as test_client:
|
|
body = test_client.get("/api/llm/history").json()
|
|
assert body["engines"] == ["gufo", "radiance"], "the vLLM entry is unconfigured and absent"
|
|
with build_client(tmp_path, monkeypatch) as test_client:
|
|
body = test_client.get("/api/llm/history").json()
|
|
assert body["engines"] == []
|
|
|
|
|
|
def test_summary_llm_enabled_follows_the_registry(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
with build_client(tmp_path, monkeypatch,
|
|
radiance_url="http://driveripper:8000") as test_client:
|
|
assert test_client.get("/api/summary").json()["llm"]["enabled"] is True
|
|
|
|
|
|
def test_an_unconfigured_radiance_never_becomes_a_job_that_could_look_broken(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Invariant 3's shape, the `disks` precedent: no configured URL, no `llm:radiance`
|
|
job at all — so nothing dials a hostname the operator never installed, and no health
|
|
record exists to turn red. The job appears exactly when the URL does.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch) as test_client:
|
|
keys = {row["key"] for row in test_client.get("/api/health").json()["sources"]}
|
|
assert not any(key.startswith("llm:") for key in keys)
|
|
with build_client(tmp_path, monkeypatch,
|
|
radiance_url="http://driveripper:8000") as test_client:
|
|
keys = {row["key"] for row in test_client.get("/api/health").json()["sources"]}
|
|
assert "llm:radiance" in keys
|
|
|
|
|
|
def _radiance_stats_doc() -> dict[str, Any]:
|
|
"""The verbatim /stats capture, its `#` header comment lines stripped."""
|
|
path = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "stats_radiance.json"
|
|
if not path.exists():
|
|
pytest.skip(f"missing fixture {path.name} — run scripts/capture-fixtures.sh --with-llm")
|
|
body = "\n".join(line for line in path.read_text().splitlines() if not line.startswith("#"))
|
|
return json.loads(body)
|
|
|
|
|
|
def test_radiance_payload_reaches_the_page_rows_and_rest(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, metrics_text) -> None:
|
|
"""The render path's input as the browser would receive it: the fixture /stats, through
|
|
the real `engine_result`, into the `llm:radiance` job — and `/api/llm/snapshot` hands
|
|
it to the page. At rest (the capture's own empty `requests[]`): the counts-otherwise
|
|
sentence, the whitelisted stats block, and `queue_cap` from the sidecar. A request in
|
|
flight: the rows verbatim under `sessions.list`. A dead sidecar: the metrics gauges
|
|
with their provenance said, and the cap `null`, never `0` (rule 2)."""
|
|
stats = _radiance_stats_doc()
|
|
rows = [{"id": 42, "model": "juggernaut", "state": "running",
|
|
"prompt_tokens": 1_200, "completed_tokens": 250}]
|
|
with build_client(tmp_path, monkeypatch, radiance_url=DEAD_URL) as test_client:
|
|
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
|
|
job = hub.jobs["llm:radiance"]
|
|
|
|
job.state.payload = engine_result("radiance", "http://r:8000",
|
|
metrics_text("radiance"), RateEstimator(),
|
|
stats=stats, now=1.0).payload
|
|
rest = next(p for p in test_client.get("/api/llm/snapshot").json()["engines"]
|
|
if p["engine"] == "radiance")
|
|
assert rest["sessions"]["list"] == stats["requests"]
|
|
if not stats["requests"]:
|
|
assert "counts, not sessions" in rest["sessions"]["detail"]
|
|
assert rest["stats"]["version"] == "1.15.1"
|
|
assert isinstance(rest["stats"]["uptime_s"], int) and rest["stats"]["uptime_s"] >= 0, \
|
|
"uptime is the engine's own seconds — volatile between captures"
|
|
assert rest["stats"]["cards"][0]["vram_used"] == stats["cards"][0]["vram_used"]
|
|
assert rest["metrics"]["queue_cap"]["value"] == stats["queue_cap"]
|
|
assert len(rest["metrics"]["spec_positions"]) == 7
|
|
|
|
busy = {**stats, "requests": rows, "running": 1}
|
|
job.state.payload = engine_result("radiance", "http://r:8000",
|
|
metrics_text("radiance"), RateEstimator(),
|
|
stats=busy, now=1.0).payload
|
|
again = next(p for p in test_client.get("/api/llm/snapshot").json()["engines"]
|
|
if p["engine"] == "radiance")
|
|
assert again["sessions"]["list"] == rows, "the rows are not the verbatim passthrough"
|
|
assert again["sessions"]["source"] == "radiance /stats"
|
|
assert again["sessions"]["detail"] is None
|
|
|
|
job.state.payload = engine_result("radiance", "http://r:8000",
|
|
metrics_text("radiance"), RateEstimator(),
|
|
now=1.0).payload # stats=None: dead sidecar
|
|
dead = next(p for p in test_client.get("/api/llm/snapshot").json()["engines"]
|
|
if p["engine"] == "radiance")
|
|
assert dead["sessions"]["queue_cap"] is None, "null, not 0 — rule 2"
|
|
assert "gauges" in dead["sessions"]["detail"]
|
|
assert "stats" not in dead, "no stats block when the sidecar never answered"
|
|
assert dead["metrics"]["queue_cap"]["value"] is None
|
|
|
|
|
|
def wait_for_state(client: TestClient, path: str, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
response = client.get(path)
|
|
if response.status_code == 200:
|
|
return response.json()
|
|
time.sleep(0.1)
|
|
pytest.fail(f"{path} never answered")
|
|
|
|
|
|
def test_target_problems_are_surfaced_not_swallowed(client) -> None:
|
|
body = client.get("/api/config").json()
|
|
assert body["system"]["target_problems"] == []
|
|
|
|
|
|
# ---- the credential the server refuses to start without (phase 11) --------- #
|
|
|
|
|
|
def test_no_verifier_and_no_seed_means_the_server_does_not_come_up(tmp_path: Path) -> None:
|
|
"""Fail closed, at the one place a mistake is unmissable: startup.
|
|
|
|
Task 03 is what demands a session on every route; this is the half before it — a deployment
|
|
with neither a stored `auth.verifier` row nor `DASH_ADMIN_PASSWORD` must not run open, so
|
|
the lifespan raises instead of publishing a context, starting the pollers, or serving a
|
|
page. Asserting that nothing was written is part of it: "refused" that seeded a row on the
|
|
way out would leave the next start half-configured.
|
|
"""
|
|
db = str(tmp_path / "no-credential.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=""), store)
|
|
try:
|
|
with pytest.raises(RuntimeError) as caught, TestClient(app):
|
|
pytest.fail("the lifespan yielded with no admin credential")
|
|
message = str(caught.value)
|
|
assert "DASH_ADMIN_PASSWORD" in message, "the operator's fix is in the exit output"
|
|
assert "auth.verifier" in message and "DELETE FROM meta" in message
|
|
assert not hasattr(app.state, "ctx"), "nothing is published, so nothing can serve"
|
|
assert store.get_meta("auth.verifier") is None
|
|
assert store.get_meta("auth.session_secret") is None
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
def test_the_same_server_starts_once_the_environment_seeds_a_password(tmp_path: Path) -> None:
|
|
"""The other side of the gate, so the failure above cannot be satisfied by a broken app.
|
|
|
|
With a seed the process comes up, the state hangs off the context (task 03 reads it on
|
|
every request), and both rows are durable — which is also what lets the operator delete
|
|
`DASH_ADMIN_PASSWORD` from the deployment after the first boot.
|
|
"""
|
|
db = str(tmp_path / "seeded.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
try:
|
|
with TestClient(app) as client:
|
|
assert client.get("/api/health").status_code == 200 # public: the healthcheck
|
|
state = app.state.ctx.auth
|
|
assert state is not None
|
|
assert state.seeded is True and state.env_ignored is False
|
|
assert state.iterations == FAST_PBKDF2
|
|
assert auth.verify_password(ADMIN_PASSWORD, state.verifier) is True
|
|
assert store.get_meta("auth.verifier") == state.verifier
|
|
assert store.get_meta("auth.session_secret") == state.secret
|
|
assert ADMIN_PASSWORD not in state.verifier + state.secret
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
# ---- pages ---------------------------------------------------------------- #
|
|
|
|
@pytest.mark.parametrize("path,title", [
|
|
("/", "overview"), ("/system", "system"), ("/disks", "disks"), ("/llm", "llm"),
|
|
("/ups", "ups"), (GATEWAY_PAGE, "gateway"),
|
|
])
|
|
def test_pages_serve_and_declare_themselves(client, path, title) -> None:
|
|
response = client.get(path)
|
|
assert response.status_code == 200
|
|
assert "text/html" in response.headers["content-type"]
|
|
assert title in response.text.lower()
|
|
|
|
|
|
def test_no_page_loads_anything_from_the_internet(client) -> None:
|
|
"""No CDN, no webfonts: this dashboard has to work with no route to the internet.
|
|
|
|
`/login` is not in `MARKUP_PAGES` and cannot be: this `client` is signed in, a signed-in
|
|
`GET /login` is a `303`, and following it would audit `/`'s bytes under the wrong name. The
|
|
same fragments are checked against the real login page, unsigned, by
|
|
`tests/test_auth_api.py::test_the_login_page_loads_nothing_from_the_internet`.
|
|
"""
|
|
for path in MARKUP_PAGES:
|
|
html = client.get(path).text
|
|
for fragment in ("//cdn.", "https://fonts.", "//unpkg.com", "//cdnjs"):
|
|
assert fragment not in html, f"{path} references {fragment}"
|
|
|
|
|
|
def test_static_assets_are_served(client) -> None:
|
|
for asset in ASSETS:
|
|
assert client.get(asset).status_code == 200, asset
|
|
|
|
|
|
# ---- /settings: the account page (phase 11 task 06) ----------------------- #
|
|
#
|
|
# `/settings` is the operator's own surface: the session this browser holds, a sign-out, and the
|
|
# one form that changes the admin password. Its HTTP surface is tested twice on purpose — the
|
|
# route-enumeration test in `tests/test_auth_api.py` proves nothing outside the public list
|
|
# answers anonymously (which is how `/settings` is gated for free by being in `PAGES` in
|
|
# `app.py`), and the assertions here pin the **page**: the form fields a password manager needs,
|
|
# the outcome line that is a live region, the precedence sentence that stops the "I edited the
|
|
# env file and nothing happened" support call, and the masthead link that is the only way in.
|
|
|
|
def test_the_settings_page_carries_the_session_and_the_form(client) -> None:
|
|
response = client.get(SETTINGS_PAGE)
|
|
assert response.status_code == 200
|
|
assert "text/html" in response.headers["content-type"]
|
|
html = response.text
|
|
# the session panel and its way out
|
|
for fragment in ('id="sign-out"', 'id="session-user"', 'id="session-expires"',
|
|
'id="session-left"'):
|
|
assert fragment in html, f"/settings has no {fragment}"
|
|
# the change-password form: three fields, each labelled, each with the floor spelled out,
|
|
# and the autocomplete values a password manager needs to fill the right one
|
|
for fragment in ('id="current-password"', 'autocomplete="current-password"',
|
|
'id="new-password"', 'autocomplete="new-password"',
|
|
'id="confirm-password"', 'id="change-password"',
|
|
'<label for="current-password">', '<label for="new-password">',
|
|
'<label for="confirm-password">', 'aria-describedby="password-hint"'):
|
|
assert fragment in html, f"/settings' form is missing {fragment}"
|
|
assert "8 characters" in html, "the form stopped naming the length floor it enforces"
|
|
# the outcome line is announced, because the operator asked for it and nothing else will
|
|
assert 'id="settings-result"' in html and 'role="status"' in html
|
|
|
|
|
|
def test_the_settings_page_states_the_precedence_rule(client) -> None:
|
|
"""The sentence that makes the env file's losing half legible on the page that wins.
|
|
|
|
`README.md` documents the same rule, but the operator reading it there is deciding what to
|
|
write in a file; the operator reading *this* page just changed a password and needs to know
|
|
why a container env will not undo it.
|
|
"""
|
|
html = client.get(SETTINGS_PAGE).text
|
|
assert "DASH_ADMIN_PASSWORD" in html
|
|
assert "auth.verifier" in html
|
|
assert "never overwrites" in html
|
|
|
|
|
|
def test_the_settings_page_is_not_one_of_the_dashboards(client) -> None:
|
|
"""The tabs are there to leave with; nothing marks itself current, because nothing is.
|
|
|
|
A tab marked current on a page that is not in the tab list claims a location the app does not
|
|
have, and the six dashboards' own masthead pin (`aria-current` exactly once) relies on that
|
|
marker meaning something.
|
|
"""
|
|
html = client.get(SETTINGS_PAGE).text
|
|
assert '<nav class="tabs"' in html
|
|
assert 'aria-current="page"' not in html
|
|
for path in PAGES:
|
|
assert f'href="{path}"' in html, f"/settings cannot navigate to {path}"
|
|
# The fleet badges belong here too (`TODO.md:3`): the container is what made them appear on
|
|
# the six dashboards, and a page that renders the masthead without it is the page where they
|
|
# "disappear". `Dash.initChrome()` fills it from `/api/health` exactly as it fills the others.
|
|
assert 'id="nav-status"' in html, "/settings lost the fleet badges the operator asked for"
|
|
|
|
|
|
def test_the_settings_page_refuses_a_caller_with_no_session(tmp_path: Path) -> None:
|
|
"""One page-level assertion of the gate, not a second enumeration of it.
|
|
|
|
`tests/test_auth_api.py::test_every_route_that_is_not_public_requires_a_session` is the
|
|
general proof; this one exists so a regression in `/settings` names `/settings`.
|
|
"""
|
|
db = str(tmp_path / "settings-gate.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
try:
|
|
with TestClient(app, follow_redirects=False) as anon:
|
|
page = anon.get(SETTINGS_PAGE, headers={"accept": "text/html"})
|
|
assert page.status_code == 303
|
|
assert page.headers["location"] == f"/login?next={SETTINGS_PAGE}"
|
|
data = anon.get(SETTINGS_PAGE, headers={"accept": "application/json"})
|
|
assert data.status_code == 401
|
|
assert data.json() == {"ok": False, "detail": "sign in required",
|
|
"login": "/login"}
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
def test_every_page_links_to_both_admin_pages(client, tmp_path: Path) -> None:
|
|
"""`⚙ Account` and `⚷ Certs` are the only way to the two admin pages, so they are everywhere.
|
|
|
|
One loop over all eight signed-in pages (`MARKUP_PAGES`), because the control row is the *same*
|
|
on all of them: `⚷ Certs` → `⚙ Account` → theme toggle → clock, in `.masthead-right` and
|
|
**outside** `.tabs` (the tab strip's structure — six dashboards, one of them current — is pinned
|
|
by `test_the_disks_tab_is_in_the_masthead_of_every_page`, and a seventh entry with no
|
|
`aria-current` would break what that marker means).
|
|
|
|
Both links are on the pages they name, which **inverts** phase 15's "no self-link"
|
|
precedent on the operator's decision (`TODO.md:4` — "clicking the certs tab causes the certs
|
|
button to disappear … These should remain visible"): masthead chrome that changes shape
|
|
depending on where you already are reads as a broken control, while a link to the page you
|
|
are on is a no-op that answers with the page you were looking at.
|
|
|
|
The clock last is phase 25 task 03 (`TODO.md:5` — "The time should show up as the farthest
|
|
right item on the navbar"). It is pinned as **markup**, not as a CSS `order` property, because
|
|
that is what makes the claim true: task 02 left the control row `justify-content: flex-end`, so
|
|
the last child of `.masthead-right` *is* the farthest-right item, and an `order:` override the
|
|
pin could not see would leave this test green over a bar that says something else. (Below 900 px
|
|
the clock is hidden by the long-standing `.clock` media rule — a readout, not a control, so
|
|
hiding it is not the "no control is hidden to fit the width" rule this app keeps — and the
|
|
markup order this test reads is width-independent.)
|
|
"""
|
|
account_open = '<a class="ghost account" href="/settings"'
|
|
certs_open = '<a class="ghost account" href="/certs"'
|
|
# the opening tag **and** its label: the claim is the cluster does not change shape between
|
|
# pages, and `/certs` shipped a theme toggle missing its `aria-pressed` for exactly the
|
|
# reason nothing ever compared the seven markups
|
|
account = f'{account_open} title="Session and password">⚙ Account</a>'
|
|
certs_link = f'{certs_open} title="Agent certificates">⚷ Certs</a>'
|
|
theme = '<button class="ghost" id="theme-toggle" type="button" aria-pressed="false">'
|
|
# The other half of the clock claim, from the stylesheet side: no `order:` on anything in the
|
|
# bar, because a re-ordering property would leave this markup audit describing a bar that
|
|
# paints in some other order (`data/logs/25-task03-verify.cjs` measures the painted order at
|
|
# both widths; this is the served-byte guard under that measurement).
|
|
css = _css(client)
|
|
for selector in (".masthead", ".masthead-right", "#nav-status", ".clock"):
|
|
assert "order" not in _declarations(css, selector), \
|
|
f"{selector} grew an `order:` declaration — the clock's place in the row is markup"
|
|
for path in MARKUP_PAGES:
|
|
html = client.get(path).text
|
|
assert certs_link in html, f"{path} has no route to the certificate authority"
|
|
assert account in html, f"{path} has no route to the account page"
|
|
before_theme = html[:html.index('id="theme-toggle"')]
|
|
assert certs_link in before_theme, f"{path} put the Certs link after the theme toggle"
|
|
assert account in before_theme, f"{path} put the Account link after the theme toggle"
|
|
assert before_theme.index(certs_link) < before_theme.index(account), \
|
|
f"{path} orders the two admin controls differently from the dashboards"
|
|
# …and the clock after the toggle, i.e. last in `.masthead-right`: on the `flex-end`
|
|
# control row that is the farthest-right item on the bar (TODO.md:5).
|
|
assert html.index('id="clock"') > html.index('id="theme-toggle"'), \
|
|
f"{path} does not end its control row with the clock"
|
|
tabs = html[html.index('<nav class="tabs"'):html.index("</nav>")]
|
|
assert "/certs" not in tabs, f"{path} made Certs a dashboard tab"
|
|
assert "/settings" not in tabs, f"{path} made Account a dashboard tab"
|
|
assert theme in html, f"{path}'s theme toggle no longer matches the other seven"
|
|
|
|
# The one page that renders neither, and its own client: `client` is signed in, and a
|
|
# signed-in `GET /login` is a `303` to the dashboard, which would audit `/` and call it
|
|
# `/login` (the trap documented at `test_the_login_page_carries_no_nav_and_no_toggle`).
|
|
# Pre-auth there is nothing to administer, so the masthead stays brand link + theme toggle.
|
|
db = str(tmp_path / "admin-links-login.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
try:
|
|
with TestClient(app) as anon:
|
|
response = anon.get("/login")
|
|
assert response.status_code == 200, \
|
|
"a signed-out /login must be the form, not a bounce"
|
|
login_html = response.text
|
|
finally:
|
|
store.close()
|
|
# the opening tag, so an `href` under any other class or label still fails here
|
|
for link in (certs_open, account_open):
|
|
assert link not in login_html, f"/login offers {link} to a caller who cannot use it"
|
|
|
|
|
|
def test_the_certs_page_is_a_page_and_not_a_dashboard(client) -> None:
|
|
"""`/certs` borrows the dashboard chrome and none of the dashboard meaning (task 15.05).
|
|
|
|
Six tabs with nothing current, the five fleet badges like every other masthead (phase 25:
|
|
the badges report the fleet, and that report is the point of the site on this page too), and
|
|
the same head and stylesheet as every other page — this is the
|
|
`test_the_settings_page_is_not_one_of_the_dashboards` assertion for the second admin page,
|
|
and it is what a tab in the strip for the certificate authority would fail on. (Phase 29
|
|
renamed it: `/gateway` **is** the sixth dashboard now, so "sixth" no longer means "not a
|
|
dashboard".)
|
|
"""
|
|
html = client.get(CERTS_PAGE).text
|
|
assert html.count('aria-current="page"') == 0
|
|
tabs = re.findall(r'<a href="([^"]+)"[^>]*aria-current="page"', html)
|
|
assert tabs == [], f"/certs marked a tab current: {tabs}"
|
|
assert 'id="nav-status"' in html, "/certs lost the fleet badges the operator asked for"
|
|
for path in PAGES:
|
|
assert f'href="{path}"' in html, f"/certs cannot navigate to {path}"
|
|
assert '/static/dash.css' in html
|
|
|
|
|
|
def test_the_certs_page_carries_the_authority_and_the_agents_table(client) -> None:
|
|
"""The three blocks the page is: the root, the client line, and one row per host.
|
|
|
|
Pinned on the served bytes rather than on a browser: the root and client panels and the table
|
|
are markup, and the two rows of the table body are built from `GET /api/certs` by
|
|
`page-certs.js` — so the per-row action labels are asserted on the served **script**, the way
|
|
`/ups` pins its command buttons. `tests/test_api.py` has no JS runner (a locked anchor), and a
|
|
label that shipped in neither file would be an invisible button either way.
|
|
"""
|
|
html = client.get(CERTS_PAGE).text
|
|
js = client.get("/static/page-certs.js").text
|
|
|
|
assert "Root CA" in html and 'id="root-serial"' in html
|
|
assert 'id="root-expires"' in html and 'id="root-left"' in html
|
|
assert "Rotate root CA" in html, "the CA-wide action disappeared"
|
|
assert "Dashboard client" in html and 'id="client-serial"' in html
|
|
assert 'id="client-expires"' in html, "/certs lost the client expiry"
|
|
assert 'id="agents-table"' in html and "<tbody></tbody>" in html
|
|
assert 'id="agents-empty"' in html and "hidden" in html
|
|
|
|
for label in ("Rotate", "Revoke", "bundle"):
|
|
assert f"'{label}'" in js, f"the {label} action lost its label"
|
|
for route in ("/rotate", "/revoke", "/bundle"):
|
|
assert route in js, f"the page no longer reaches {route}"
|
|
assert "/api/certs/root/rotate" in js
|
|
|
|
|
|
def test_the_certs_agents_table_scrolls_to_its_last_column(client) -> None:
|
|
"""`#agents-table` carries the `.scroll` modifier, and the stylesheet still styles it.
|
|
|
|
`TODO.md:9`: at phone width the serial, expiry, `san` and controls columns were clipped by
|
|
`.card { overflow: hidden }` with no way to reach them. `dash.css` turns `table.data` into
|
|
a scroll container through the `scroll` class, so the class and the rule are one mechanism
|
|
and are pinned together — the phase-18 idiom of comparing the two places a contract lives,
|
|
because a rename on either side silently un-scrolls the table.
|
|
|
|
(Phase 27 task 01 added a second door: at ≤640px *every* `table.data` scrolls horizontally.
|
|
That does not retire this pin — `.scroll` out-ranks it, two classes to one, so this table's
|
|
both-axis box and its 320 px height cap are still decided here, on a phone included.)
|
|
"""
|
|
html = client.get(CERTS_PAGE).text
|
|
assert '<table class="data scroll" id="agents-table">' in html, \
|
|
"the agents table lost its .scroll modifier — the controls column is unreachable at 360px"
|
|
|
|
css = client.get("/static/dash.css").text
|
|
start = css.index("table.data.scroll {")
|
|
rule = css[start:css.index("}", start)]
|
|
assert "overflow: auto" in rule, "table.data.scroll no longer scrolls — the class names nothing"
|
|
|
|
|
|
def test_the_certs_page_prints_the_names_the_authority_mints(client) -> None:
|
|
"""The CN of each certificate is markup, so the literal is pinned to `certs.py`.
|
|
|
|
`MIN_PASSWORD_LENGTH`'s idiom: there is no build step to share a Python constant with a page,
|
|
so a page that restates one says it in markup and a test compares the two. Printing a CN the
|
|
module did not sign with would be the page inventing a fact about a stored certificate.
|
|
"""
|
|
html = client.get(CERTS_PAGE).text
|
|
assert f">{certs.ROOT_CN}<" in html, "the root panel's CN is not what certs.py signs"
|
|
assert f">{certs.CLIENT_CN}<" in html, "the client line's CN is not what certs.py signs"
|
|
|
|
|
|
def test_the_certs_page_reads_only_fields_the_cert_api_publishes(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""End to end, in both directions: the page's column table and `GET /api/certs` are one shape.
|
|
|
|
The served script's `COLUMNS` entries name every payload key the page indexes, so this is the
|
|
`SLOT_FIELDS` device on the surface where a rename is worst: a `deployed_bundle_rev` that
|
|
quietly became `agent_bundle_rev` would print `n/a` beside a healthy fleet forever.
|
|
"""
|
|
script = "/static/page-certs.js"
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(3),
|
|
host_interval=3600.0) as client:
|
|
js = client.get(script).text
|
|
names: set[str] = set()
|
|
for listed in re.findall(r"fields: \[([^\]]*)\]", js):
|
|
names.update(re.findall(r"'([^']+)'", listed))
|
|
assert names == CERT_AGENT_KEYS, sorted(names ^ CERT_AGENT_KEYS)
|
|
|
|
client.post("/api/certs/deskwork/rotate")
|
|
for row in cert_agent_rows(client):
|
|
assert len(row) == 8
|
|
for name in names:
|
|
assert name in row, f"the page indexes `{name}` and the API does not publish it"
|
|
|
|
# …and the two authority blocks: the page prints a serial and an expiry for each, which is
|
|
# exactly the key set the API answers there.
|
|
body = cert_document(client)
|
|
for block in ("root", "client"):
|
|
assert set(body[block]) == CERT_TIMESERIAL_KEYS
|
|
for name in CERT_TIMESERIAL_KEYS:
|
|
assert f".{name}" in js, f"the page stopped reading `{name}`"
|
|
|
|
|
|
def test_the_certs_page_never_colours_a_certificate(client) -> None:
|
|
"""No severity vocabulary reaches this page at all — an expiry is a date, not a verdict.
|
|
|
|
`/api/certs` sends no `severity` anywhere (task 04, and `severity.py` rates no certificate),
|
|
so a page that coloured an expiring cert would be inventing a threshold in JavaScript. The one
|
|
colour on the page is the payload's `status` word through a word→class map, and `unissued`
|
|
maps to no class at all — a certificate that does not exist must not borrow green.
|
|
"""
|
|
js = _code(client.get("/static/page-certs.js").text)
|
|
assert "severity" not in js, "/certs grew a verdict the API does not send"
|
|
assert "severityFrom" not in js and "severityFor" not in js
|
|
table = js[js.index("const STATUS_CHIP = {"):js.index("};")]
|
|
assert "active: 'ok'" in table and "revoked: 'fail'" in table
|
|
assert "unissued: ''" in table, "an unissued host would be coloured"
|
|
assert "not_after" not in table and "serial" not in table, "an expiry became a verdict"
|
|
|
|
|
|
def test_the_certs_page_asks_before_it_writes_and_offers_nothing_the_api_refuses(
|
|
client) -> None:
|
|
"""Every write is confirmed, and no row offers a control its own payload rules out.
|
|
|
|
`confirm()` and not the typed `DANGER` word: that gate belongs to commands that cut power,
|
|
and this page has none — the worst outcome here is a host that is not dialled until somebody
|
|
installs a bundle. The guards are the API's, restated: a row outside `HOST_TARGETS` gets no
|
|
buttons at all (`404 {"configured": false}` is all three routes' first line), a never-issued
|
|
host has nothing to revoke and no bundle to download, and the bundle download is a plain
|
|
`GET` link because a session cookie is all a signed `GET` needs.
|
|
"""
|
|
js = _code(client.get("/static/page-certs.js").text)
|
|
assert "window.confirm(" in js
|
|
assert "DANGER" not in js, "the power-cut word migrated to a page with no power cut"
|
|
assert "entry.configured" in js and "not in HOST_TARGETS" in js
|
|
assert "entry.status !== 'unissued'" in js, "a row could revoke nothing"
|
|
assert "entry.serial" in js
|
|
assert "bundle.href = '/api/certs/'" in js
|
|
assert "method: 'POST'" in js
|
|
# …and the refusal is text, never markup: these are server sentences
|
|
assert "innerHTML" not in js
|
|
|
|
|
|
def test_the_certs_page_prints_the_restart_sentence_the_api_spoke(client) -> None:
|
|
"""`restart_required` prints the route's `hint`, not a sentence the page wrote for itself.
|
|
|
|
The restart fact lives in `api.py` (the CA and the client pair are startup material, so the
|
|
table is new before the socket is). A page copy of it is the version that quietly stops
|
|
saying "and every bundle needs re-installing".
|
|
"""
|
|
js = client.get("/static/page-certs.js").text
|
|
assert "restart_required" in js
|
|
assert "body.hint" in js
|
|
assert "restart the dashboard" not in js.lower(), "the page invented the restart sentence"
|
|
|
|
|
|
def test_the_password_floor_the_form_checks_is_the_one_the_api_enforces(client) -> None:
|
|
"""The client-side length check is a courtesy; the server's is the rule.
|
|
|
|
There is no build step to share the constant (a locked anchor), so the two literals are
|
|
pinned together here: a floor raised in `api.py` alone would leave the form promising eight
|
|
characters to a request the server answers `422`.
|
|
"""
|
|
js = client.get("/static/page-settings.js").text
|
|
found = re.search(r"const MIN_PASSWORD_LENGTH = (\d+);", js)
|
|
assert found, "page-settings.js no longer names its length floor"
|
|
assert int(found.group(1)) == MIN_PASSWORD_LENGTH
|
|
|
|
|
|
def test_a_rotated_secret_clears_the_cached_session_every_page_uses(client) -> None:
|
|
"""`Dash.resetAuth()` is the difference between a password change and a 403 dashboard.
|
|
|
|
`POST /api/auth/password` rotates the signing secret on purpose (`auth.update_verifier`),
|
|
which invalidates the CSRF token `ensureAuth()` memoised. Every page writes through
|
|
`fetchJSON`, which reads that cache — so unless the change clears it, the operator changes
|
|
the password, clicks *beeper disable* on `/ups`, and gets `missing or invalid CSRF token`
|
|
until they reload the page. A bounce on a `401` clears it for the same reason: the session it
|
|
described no longer exists.
|
|
"""
|
|
app_js = client.get("/static/app.js").text
|
|
assert "function resetAuth(" in app_js
|
|
exports = app_js.rsplit("return {", 1)[1].split("}", 1)[0]
|
|
assert re.search(r"\bresetAuth\b", exports), "Dash.resetAuth is not exported"
|
|
reset = app_js[app_js.index("function resetAuth("):app_js.index("function bounceToLogin(")]
|
|
for field in ("loaded: false", "authenticated: false", "csrf: null"):
|
|
assert field in reset, f"resetAuth() left `{field}` cached"
|
|
assert "authPromise = null" in reset, \
|
|
"resetAuth() left an in-flight status call memoised — the fresh write would reuse it"
|
|
bounce = app_js[app_js.index("function bounceToLogin("):app_js.index("const READ_METHODS")]
|
|
assert "resetAuth()" in bounce, "a 401 bounce kept the dead session's token cached"
|
|
|
|
js = client.get("/static/page-settings.js").text
|
|
assert js.count("Dash.resetAuth()") >= 2, \
|
|
"the settings page must reset the cached session after both a change and a sign-out"
|
|
assert "textContent" in js and "innerHTML" not in js, \
|
|
"/settings renders a server sentence as markup"
|
|
|
|
|
|
# ---- the severity language in the static assets --------------------------- #
|
|
#
|
|
# There is no JS test runner (a locked anchor: adding one would mean a build step).
|
|
# What *can* be asserted end-to-end, from the served bytes, is the contract the pages
|
|
# depend on: the hero style exists, its colour comes from the shared ok/warn/fail tokens,
|
|
# no parallel `.sev-*` vocabulary appeared, and `Dash.hero()` reads the verdict instead of
|
|
# comparing the number to a threshold in JavaScript.
|
|
|
|
ASSETS = ("/static/dash.css", "/static/app.js", "/static/charts.js",
|
|
"/static/page-index.js", "/static/page-disks.js", "/static/page-system.js",
|
|
"/static/page-llm.js", "/static/page-ups.js", "/static/page-login.js",
|
|
"/static/page-settings.js", "/static/page-certs.js", "/static/page-gateway.js")
|
|
|
|
|
|
def _css(client: TestClient) -> str:
|
|
return client.get("/static/dash.css").text
|
|
|
|
|
|
def _css_section(css: str, name: str) -> str:
|
|
"""One `/* ---- name … */` section of the stylesheet, up to the **next** section header.
|
|
|
|
Slicing to a named neighbour worked while the sheet had five sections. Every page has since
|
|
added a component section, and a slice that runs across someone else's rules accuses the
|
|
fleet block of colouring numbers it never coloured — so the boundary is structural: the end
|
|
of *this* section, whoever comes next.
|
|
"""
|
|
start = css.index(f"/* ---- {name}")
|
|
return css[start:css.index("/* ----", start + 10)]
|
|
|
|
|
|
def _hero_css(css: str) -> str:
|
|
"""Just the `hero numbers` section of the stylesheet."""
|
|
return css.split("/* ---- hero numbers", 1)[1].split("/* ---- bars", 1)[0]
|
|
|
|
|
|
def test_css_ships_the_hero_number_style(client) -> None:
|
|
css = _css(client)
|
|
hero_block = _hero_css(css)
|
|
for rule in (".hero {", ".hero .label", ".hero .value", ".hero .unit", ".hero .sub",
|
|
".hero.ok .value", ".hero.warn .value", ".hero.fail .value",
|
|
".hero-bar", ".hero-bar.ok .bar"):
|
|
assert rule in hero_block, f"dash.css lost the {rule} rule"
|
|
# a display size with fixed-width digits, so the number does not jitter between polls
|
|
assert "2.4rem" in hero_block
|
|
assert "tabular-nums" in hero_block
|
|
|
|
|
|
def test_hero_colour_comes_from_the_shared_verdict_tokens(client) -> None:
|
|
"""`.hero` reuses `--ok/--warn/--fail`; it does not invent a palette of its own."""
|
|
hero_block = _hero_css(_css(client))
|
|
for token in ("var(--ok)", "var(--warn)", "var(--fail)", "var(--text)",
|
|
"var(--text-muted)", "var(--text-faint)"):
|
|
assert token in hero_block, f"the hero style no longer uses {token}"
|
|
|
|
|
|
def test_no_stylesheet_rule_uses_an_undeclared_token(client) -> None:
|
|
"""A typo'd `var(--x)` renders unstyled and reads as a broken panel, not an error."""
|
|
css = _css(client)
|
|
root = re.search(r":root\s*\{(.*?)\n\}", css, re.S)
|
|
assert root is not None
|
|
declared = set(re.findall(r"(--[a-z0-9-]+)\s*:", root.group(1)))
|
|
used = set(re.findall(r"var\(\s*(--[a-z0-9-]+)", css))
|
|
assert not used - declared
|
|
|
|
|
|
def _code(body: str) -> str:
|
|
"""An asset with its comments stripped — prose may *name* a class the code must not define."""
|
|
body = re.sub(r"/\*.*?\*/", " ", body, flags=re.S)
|
|
body = re.sub(r"<!--.*?-->", " ", body, flags=re.S)
|
|
return re.sub(r"^\s*//.*$", " ", body, flags=re.M)
|
|
|
|
|
|
# The `fleet` object paths `/llm`'s aggregate block reads. `engines_total` drives the
|
|
# "hidden below two engines" rule and the tag; `engines_live` is what makes a partial sum
|
|
# visible; the rest are the row itself.
|
|
FLEET_BLOCK_PATHS = frozenset({
|
|
"engines_total", "engines_live", "requests_active", "requests_waiting",
|
|
"prompt_tokens_per_second", "decode_tokens_per_second", "tokens_total",
|
|
})
|
|
# The summed half of that list — the ones that must be null, not 0, with nothing answering.
|
|
FLEET_SUM_PATHS = ("requests_active", "requests_waiting", "prompt_tokens_per_second",
|
|
"decode_tokens_per_second", "tokens_total")
|
|
|
|
|
|
def _fleet_tag(html: str) -> str:
|
|
"""The aggregate block's opening tag — the one place its `hidden` default lives."""
|
|
start = html.index('<section class="card flat fleet"')
|
|
return html[start:html.index(">", start)]
|
|
|
|
|
|
def _fleet_render_path(js: str) -> str:
|
|
"""Just the body of `renderFleet()` — the aggregate block's whole code path, comments out.
|
|
|
|
Everything the block writes goes through here, so it is the slice that can be asserted
|
|
verdict-free without tripping over the engine panels' rated KV sparkline next door.
|
|
"""
|
|
js = _code(js)
|
|
return js[js.index("function renderFleet("):js.index("async function refresh(")]
|
|
|
|
|
|
def _fleet_field_metrics(js: str) -> list[str]:
|
|
"""The `fleet` keys the block's rows are bound to, in the order the block prints them."""
|
|
js = _code(js)
|
|
start = js.index("const FLEET_FIELDS = [")
|
|
table = js[start:js.index("];", start) + 2]
|
|
return re.findall(r"metric: '([a-z_]+)'", table)
|
|
|
|
|
|
def test_no_parallel_severity_vocabulary_exists_anywhere(client) -> None:
|
|
"""One word per colour: `ok|warn|fail`, as in severity.py and the existing components."""
|
|
for asset in (*ASSETS, *MARKUP_PAGES):
|
|
body = _code(client.get(asset).text)
|
|
assert ".sev-" not in body, f"{asset} invented a .sev-* class"
|
|
assert not re.search(r"classList\.(?:add|toggle)\(\s*[\"'](?:danger|good|warning)[\"']",
|
|
body), f"{asset} invented a synonym for fail"
|
|
|
|
|
|
def test_app_js_exposes_the_hero_helpers(client) -> None:
|
|
js = client.get("/static/app.js").text
|
|
assert "function hero(" in js
|
|
assert "function severityFrom(" in js
|
|
exports = js.rsplit("return {", 1)[1].split("}", 1)[0]
|
|
for name in ("hero", "severityFrom", "severityFor"):
|
|
assert re.search(rf"\b{name}\b", exports), f"Dash.{name} is not exported"
|
|
|
|
|
|
def test_hero_reads_the_verdict_instead_of_a_threshold(client) -> None:
|
|
"""`hero()` may only colour what the API rated — no thresholds re-derived in JS."""
|
|
js = client.get("/static/app.js").text
|
|
start = js.index("function hero(")
|
|
body = js[start:js.index("\n function setDot", start)]
|
|
assert "severityFrom(" in body
|
|
assert "severityFor(" not in body
|
|
# an unknown value gets n/a and no colour class at all
|
|
assert "naText" in body
|
|
assert re.search(r"raw != null", body)
|
|
|
|
|
|
def test_severity_for_is_kept_as_the_documented_fallback(client) -> None:
|
|
"""Every page call site is gone now that phase 05 converted `/ups`.
|
|
|
|
`/` was converted in phase 02, `/system` in phase 03, `/llm` in phase 04 and `/ups` in
|
|
phase 05, so no page colours anything from a threshold it wrote itself. The function
|
|
itself stays: it is the documented fallback `setBar()` uses when a caller names no
|
|
verdict, and a page that ever needs a verdict the API does not send should add it to
|
|
`severity.py` rather than here — which is what the docstring on it says.
|
|
"""
|
|
js = client.get("/static/app.js").text
|
|
assert "FALLBACK ONLY" in js
|
|
assert "severityFor" in js, "the documented fallback itself was deleted"
|
|
for page in PAGE_SCRIPTS.values():
|
|
assert "severityFor" not in client.get(f"/static/{page}").text, \
|
|
f"{page} colours something from a threshold of its own"
|
|
|
|
|
|
def test_overview_colours_from_the_payload_not_from_js_thresholds(client) -> None:
|
|
"""`page-index.js` reads verdicts; it does not compare a number to a bound."""
|
|
js = client.get("/static/page-index.js").text
|
|
assert "severityFrom" in js
|
|
assert "severityFor" not in js
|
|
|
|
|
|
def test_system_page_colours_from_the_payload_not_from_js_thresholds(client) -> None:
|
|
"""`page-system.js` hero cards paint payload verdicts, not thresholds of their own.
|
|
|
|
Phase 03 task 02's promise: every colour on a host card comes from a `severity` dict
|
|
`severity.py` produced (`cpu.severity.usage_pct`, `memory.severity.used_pct`,
|
|
`disks.severity.worst_used_pct`, `cpu.severity.temp_c`).
|
|
A threshold written here could disagree with the overview's alerts; a hero with no
|
|
verdict prints `n/a` uncoloured, which is `Dash.hero()`'s contract.
|
|
|
|
The GPU slot names no verdict on purpose (phase 06): `severity.THRESHOLDS` has no
|
|
`gpu.*` row, the payload carries no `gpus[].severity`, and the hero is built without
|
|
`severity` or `bar` — a bar under a verdictless number would be a verdict by shape.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
assert "severityFor(" not in js, "the system page re-invented a threshold"
|
|
assert "Dash.hero(" in js
|
|
for hook in ("cpu.severity.usage_pct", "memory.severity.used_pct",
|
|
"disks.severity.worst_used_pct", "cpu.severity.temp_c"):
|
|
assert hook in js, f"the card no longer names {hook} as a colour source"
|
|
# and the GPU hero grades nothing: no verdict hook, no utilisation bar
|
|
assert "gpus[].severity" not in js, "the GPU slot invented a colour source again"
|
|
gpu_slot = js[js.index("function renderGpuSlot("):js.index("function renderGpuExtra(")]
|
|
assert "severity:" not in gpu_slot, "the GPU hero passes a severity option again"
|
|
assert "bar:" not in gpu_slot, "the GPU hero grew a bar back (a verdict by shape)"
|
|
assert "hero-bar" not in gpu_slot, "the GPU hero rebuilt the bar variant of .hero"
|
|
|
|
|
|
#: Every cell of the `/system` GPU card's value grid, in render order, with the `gpus[]`
|
|
#: key(s) it prints. This is the `SLOT_FIELDS` device applied to the four values phase 21
|
|
#: task 02 created: the table is read out of the served `GPU_VALUE_FIELDS` and resolved
|
|
#: against every captured host shape *and* against `/api/system/snapshot`, so a rename on
|
|
#: either side fails a test instead of printing `n/a` beside a healthy fleet — and so the
|
|
#: dense ` · `-joined sub-line the TODO complained about cannot come back.
|
|
GPU_VALUE_CELLS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("Model", ("model",)),
|
|
("VRAM", ("vram_used_bytes", "vram_total_bytes")),
|
|
("Pool", ("mem_used_pct",)),
|
|
("Temp", ("temp_c",)),
|
|
)
|
|
|
|
|
|
def _gpu_value_fields(js: str) -> list[tuple[str, tuple[str, ...]]]:
|
|
"""The served `GPU_VALUE_FIELDS` table as `(label, payload keys)` rows."""
|
|
table = re.search(r"const GPU_VALUE_FIELDS = \[(.*?)\n\];", js, re.S)
|
|
assert table, "page-system.js no longer has a GPU_VALUE_FIELDS table"
|
|
rows: list[tuple[str, tuple[str, ...]]] = []
|
|
for entry in re.findall(r"\{[^{}]*\}", table.group(1)):
|
|
label = re.search(r"label: '(\w+)'", entry)
|
|
field = re.search(r"field: '(\w+)'", entry)
|
|
assert label and field, f"a GPU value cell names no label or field: {entry!r}"
|
|
total = re.search(r"total: '(\w+)'", entry)
|
|
rows.append((label.group(1),
|
|
(field.group(1), total.group(1)) if total else (field.group(1),)))
|
|
return rows
|
|
|
|
|
|
def test_gpu_values_are_four_labelled_cells_not_a_dense_sub_line(client) -> None:
|
|
"""`TODO.md:12` — "… is too much info for such small text and should be its own values."
|
|
|
|
The GPU slot used to print `Radeon 8060S Graphics · VRAM 180.2 MiB / 512.0 MiB · pool
|
|
79% · 69 °C` as one `.sub` line in 11px mono. It is now four labelled cells, and the
|
|
pin is bidirectional: the table has the four rows in order, and the joined-sentence
|
|
builder that produced the TODO's quoted string is gone (`gpuSub` and its
|
|
`bits.join(' · ')` are the thing that made the card unreadable).
|
|
|
|
Two invariants ride on the same slice. The model string is upstream text (a PCI device
|
|
name), so it leaves through `DashCharts.escapeHtml`; and the temperature keeps its
|
|
null guard — `0 °C` would be a cool GPU that does not exist (phase 06).
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
assert _gpu_value_fields(js) == list(GPU_VALUE_CELLS), \
|
|
"the GPU value grid is no longer Model / VRAM / Pool / Temp over those payload keys"
|
|
# the °C rides in the value (the label is the word, as on the /disks card and the GPU
|
|
# detail table), and it appears exactly once in the table: one cell, one unit.
|
|
assert js.count("unit: ' °C'") == 1, "the Temp cell's unit is not the one °C on the card"
|
|
|
|
body = js[js.index("function gpuValues("):js.index("function renderGpuSlot(")]
|
|
assert "GPU_VALUE_FIELDS.map(" in body, "gpuValues() stopped rendering the table"
|
|
assert "escapeHtml" in body, "the Model cell prints an upstream string unescaped"
|
|
assert "== null" in body, "a cell lost its null guard (missing must print n/a, not 0)"
|
|
|
|
# the dense sub-line the TODO quoted is gone, container and all
|
|
assert "function gpuSub(" not in js, "gpuSub() came back"
|
|
assert "join(' · ')" not in js, "the ` · `-joined GPU footnote came back"
|
|
|
|
# "More detail" keeps the per-label breakdown: a surface change, not a de-duplication
|
|
extra = js[js.index("function renderGpuExtra("):js.index("function renderHostCard(")]
|
|
assert "Temp" in extra and "gpu.temp_c" in extra, "GPU detail lost its temperature column"
|
|
|
|
|
|
def test_gpu_value_cells_resolve_against_every_captured_host(
|
|
agent_snapshots: dict[str, dict[str, Any]]) -> None:
|
|
"""Every key the four cells print exists on every captured card — discrete GPU and APU.
|
|
|
|
`tests/card_contract.py` owns the payload table; this resolves the *page's* table
|
|
against the same three captures, which is where a rename would otherwise be caught by
|
|
an operator looking at `n/a` next to a healthy machine. `temp_c` may legitimately be
|
|
`None` (a driver with no hwmon input) — present is the contract, and the cell prints
|
|
`n/a` for it, never `0 °C`.
|
|
"""
|
|
for name in HOST_SHAPES:
|
|
payload = parse_host_snapshot(name, agent_snapshots[name], HostCollector(
|
|
RateEstimator(span=60.0)), now=1_000.0).payload
|
|
assert_card_contract(payload)
|
|
for gpu in payload["gpus"]:
|
|
for label, keys in GPU_VALUE_CELLS:
|
|
for key in keys:
|
|
assert key in gpu, f"{name}: the {label!r} cell has no {key!r} key"
|
|
if not payload["gpus"]:
|
|
# gateway: the omission's input. An empty list is a *fact* about the box, and
|
|
# `renderGpuSlot()` turns that fact into an absent slot rather than an `n/a`.
|
|
assert name == "gateway", "a capture lost its GPU row"
|
|
|
|
|
|
def test_the_gpu_value_cells_are_served_for_the_hosts_that_have_a_card(
|
|
tmp_path: Path, agent_snapshots: dict[str, dict[str, Any]]) -> None:
|
|
"""The same four keys over HTTP, on the body `/system` actually renders.
|
|
|
|
Parsing in-process proves the source; this proves the endpoint. One app with all three
|
|
hosts injected (dead ports, so no poll can overwrite what the test put there) answers
|
|
with the discrete-GPU card, the APU card, and gateway's empty list — the exact union
|
|
`renderGpuSlot()` branches on: cards present means four labelled values per card, `[]`
|
|
means no slot at all.
|
|
"""
|
|
db = str(tmp_path / "gpu-cells.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=tuple(
|
|
Target(name, DEAD_URL) for name in HOST_SHAPES)), store)
|
|
test_client = TestClient(app)
|
|
test_client.__enter__()
|
|
try:
|
|
sign_in(test_client)
|
|
hub = app.state.ctx.hub
|
|
for name in HOST_SHAPES:
|
|
hub.jobs[f"host:{name}"].state.payload = parse_host_snapshot(
|
|
name, agent_snapshots[name], HostCollector(RateEstimator(span=60.0)),
|
|
now=1_000.0).payload
|
|
hosts = {row["name"]: row for row in test_client.get("/api/system/snapshot").json()["hosts"]}
|
|
assert set(hosts) == set(HOST_SHAPES)
|
|
for name, row in hosts.items():
|
|
for gpu in row["gpus"]:
|
|
for label, keys in GPU_VALUE_CELLS:
|
|
for key in keys:
|
|
assert key in gpu, f"{name}: /api/system/snapshot has no {key!r} " \
|
|
f"for the {label!r} cell"
|
|
assert hosts["gateway"]["gpus"] == [], \
|
|
"gateway must answer [] — that is what makes the page omit the slot"
|
|
assert hosts["driveripper"]["gpus"] and hosts["deskwork"]["gpus"]
|
|
finally:
|
|
test_client.__exit__(None, None, None)
|
|
store.close()
|
|
|
|
|
|
def test_a_host_with_no_gpu_omits_the_gpu_slot_instead_of_printing_n_a(client) -> None:
|
|
"""`TODO.md:12` — "Boxes without a GPU should not report N/A but instead should omit
|
|
these values."
|
|
|
|
gateway has no card, and the sixth hero slot used to say so with a big faint `n/a` plus
|
|
the sentence "No GPU reported by this agent" — invariant 3's complaint in the one place
|
|
the page had not learned it. `renderGpuSlot()` now empties the slot and sets `hidden`,
|
|
and `[hidden] { display: none !important }` at the end of `dash.css` is what takes the
|
|
wrapper out of the `.heroes` grid so the row reflows to five. Pinned on the served
|
|
bytes (no JS runner — a locked anchor) and measured in a browser by
|
|
`data/logs/21-task02-gpu-values.cjs`.
|
|
|
|
The wrapper element itself stays in `hostCard()`: the hero row remains one static list
|
|
of six `data-k` slots (pinned by the test below), a host whose agent starts reporting a
|
|
card has something to grow into, and an empty hidden element is zero footprint.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
slot = js[js.index("function renderGpuSlot("):js.index("function renderGpuExtra(")]
|
|
assert "cell.hidden = gpus.length === 0" in slot, \
|
|
"the GPU slot no longer hides itself when the host has no card"
|
|
assert "No GPU reported" not in js, "the no-GPU n/a sentence came back"
|
|
assert "n/a" not in slot, "the GPU slot grew its own n/a fallback again"
|
|
# and the row itself keeps the six static slots, so a GPU host still has somewhere to go
|
|
assert "display: none !important" in client.get("/static/dash.css").text, \
|
|
"[hidden] stopped beating .hero-slot's display, so a hidden slot would still " \
|
|
"occupy a hero track"
|
|
|
|
|
|
def test_gpu_value_grid_ships_no_verdict_and_wraps(client) -> None:
|
|
"""The four new cells colour nothing and let a long model string wrap.
|
|
|
|
Phase 06's rule extends to them: `severity.THRESHOLDS` has no `gpu.*` row, the payload
|
|
carries no `gpus[].severity`, so neither the markup nor the grid may reach for
|
|
`--ok`/`--warn`/`--fail` or a `severity` option (a busy GPU on an LLM server is the job).
|
|
`overflow-wrap` is the other half of `TODO.md:12` — the readability complaint *is* the
|
|
20-character model string, and truncating it would keep the complaint.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
# word-boundary on purpose: the docstring in this slice is allowed to *say* "severity",
|
|
# the code is not allowed to pass one (or a bar, or a verdict class) to anything
|
|
markup = js[js.index("const GPU_VALUE_FIELDS"):js.index("function renderGpuSlot(")]
|
|
verdicts = re.findall(r"\b(ok|warn|fail|severity:|bar:|hero-bar)\b", markup)
|
|
assert not verdicts, f"the GPU value grid grew a verdict: {verdicts}"
|
|
|
|
css = client.get("/static/dash.css").text
|
|
rule = re.search(r"\.gpu-values \{([^}]*)\}", css)
|
|
assert rule, "/system lost its .gpu-values grid"
|
|
assert "auto-fit" in rule.group(1), ".gpu-values stopped adapting to the hero track"
|
|
wrap = re.search(r"\.gpu-values \.value \{([^}]*)\}", css)
|
|
assert wrap and "overflow-wrap" in wrap.group(1), \
|
|
"a long GPU model string truncates instead of wrapping"
|
|
for token in ("--ok", "--warn", "--fail"):
|
|
assert token not in rule.group(1) + (wrap.group(1) if wrap else ""), \
|
|
f"the GPU value grid coloured itself with {token} (severity.py rates no GPU)"
|
|
|
|
|
|
def test_system_host_card_is_six_heroes_without_per_core(client) -> None:
|
|
"""Each host card ships six hero slots in order: CPU, MEM, TEMP, DISK, GPU, NET.
|
|
|
|
Asserted on the served script because there is no JS test runner (a locked anchor).
|
|
The two dense `.metrics` rows and the per-core heat bars are exactly what made the
|
|
card unreadable, and "no per-core anywhere on /system" is a promise a stray
|
|
`renderCores` call would silently break — the series stays stored, the page just
|
|
stopped drawing it (PLAN.md *Dropped visuals*).
|
|
|
|
Six **slots**, not six visible heroes: the GPU slot ships `hidden` and
|
|
`renderGpuSlot()` un-hides it only for a host whose payload has a card, so gateway's
|
|
card renders five (pinned by the test above and measured in the browser by
|
|
`data/logs/21-task02-gpu-values.cjs`). The static order below is what makes that a
|
|
one-line rule instead of a re-flowing template.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
card = js[js.index("function hostCard("):js.index("function renderHostCard(")]
|
|
slots = re.findall(r'<div class="(?:hero|hero-slot)[^"]*" data-k="([^"]+)"', card)
|
|
assert slots == ["cpu", "mem", "temp", "disk", "gpus", "net"], \
|
|
"the hero row is no longer the six numbers in the agreed order"
|
|
assert re.search(r'data-k="gpus"[^>]*hidden', card), \
|
|
"the GPU slot is visible before the payload says this host has a card"
|
|
# per-core visualisation is gone from the card code entirely
|
|
assert "cores" not in card, "a per-core visual came back to the card"
|
|
assert "renderCores" not in js and "core_usage" not in js
|
|
# the NET slot is a slot with two lines and no verdict: a byte rate has no good/bad
|
|
assert 'data-k="net"' in card
|
|
assert not re.search(r'data-k="net"[^>]*data-sev', card), "the NET slot invented a colour"
|
|
# what the glance leaves out stays reachable, collapsed by default
|
|
assert "More detail" in card
|
|
for key in ("load", "freq", "dr", "dw", "dutil", "uptime", "details"):
|
|
assert f'data-k="{key}"' in card, f"{key} left the page instead of the glance"
|
|
# the panel owns its charts: the slots live inside the card, not on the page
|
|
for chart in ("util", "net"):
|
|
assert f'data-chart="{chart}"' in card, f"the panel lost its {chart} chart slot"
|
|
|
|
|
|
def test_system_page_keeps_no_fleet_wide_chart_or_metric_picker(client) -> None:
|
|
"""Nothing on `/system` plots two hosts, and nothing needs a selection to make sense.
|
|
|
|
TODO.md L4 is the instruction ("The system page doesn't need shared charts for
|
|
anything") and the overview's alerts + fleet table are what made removing them safe
|
|
(phase 02). Asserted on the served bytes because the only thing that would otherwise
|
|
notice a resurrected `<select>` is a browser: there is no JS test runner (a locked
|
|
anchor), and `test_no_page_carries_an_id_nothing_reads` cannot see ids that were deleted
|
|
correctly from the page but are still queried by the script.
|
|
"""
|
|
html = _code(client.get("/system").text)
|
|
for gone in ('id="chart-cpu"', 'id="chart-mem"', 'id="chart-disk"', 'id="chart-net"',
|
|
'id="chart-gpu"', 'id="chart-temp"', 'id="chart-detail"', 'id="detail-host"',
|
|
'id="detail-metric"', 'id="f-cpu"', 'id="f-load"', 'id="f-gpu"',
|
|
"Fleet totals", "Fleet charts", "Series detail", "<select"):
|
|
assert gone not in html, f"/system still carries {gone}"
|
|
# the two panels that are left are the per-host grid and the event feed
|
|
assert html.count("<section") == 2, "/system grew a panel back on top of the host grid"
|
|
assert 'id="hosts"' in html and 'id="events"' in html
|
|
|
|
# and the script must not reach for the deleted endpoints/ids either — a `#detail-host`
|
|
# query against a removed node is a silent null, so the picker would just never appear
|
|
js = client.get("/static/page-system.js").text
|
|
for gone in ("#chart-", "detail-host", "detail-metric", "detail-note",
|
|
"renderFleet", "makeCharts", "refreshGpuChart", "populateDetailMetrics"):
|
|
assert gone not in js, f"page-system.js still references {gone}"
|
|
|
|
|
|
def test_every_system_chart_is_drawn_inside_its_own_host_panel(client) -> None:
|
|
"""One host per chart: the legends of `/system` can never list two machine names.
|
|
|
|
The construction is a factory that takes the card, so a chart cannot be built against a
|
|
page-level id; the metric list is per host, so the GPU streams cannot leak from one
|
|
panel into another; and a panel is torn down (with its charts) when its host leaves the
|
|
payload, which is the bug class a keyed-by-host chart map exists to avoid.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
builds = re.findall(r"new DashCharts\.LineChart\(([^,]+),", js)
|
|
assert builds, "the system page builds no charts at all"
|
|
# every chart is built from a `[data-chart]` slot inside the host card, never from a
|
|
# page-level id — which is what makes "one host per chart" structural, not a convention
|
|
assert all(re.search(r"(?:card|slot)\.querySelector\(.\[data-chart=", b) for b in builds), \
|
|
f"a chart is built outside a host panel: {builds}"
|
|
# three construction sites, one per slot (util, net, gpu) — never one per metric
|
|
assert len(builds) == 3, f"charts are not built one per panel slot: {builds}"
|
|
assert sorted(re.findall(r'data-chart=\\?"(\w+)"', " ".join(builds))) == ["gpu", "net", "util"]
|
|
|
|
factory = js[js.index("async function renderHostCharts("):js.index("function pruneHosts(")]
|
|
assert "hosts=${encodeURIComponent(host.name)}" in factory, \
|
|
"the per-host chart query is not scoped to one host"
|
|
assert "&metrics=" in factory and "state.range" in factory, \
|
|
"a host's charts need one metric list and the selected window"
|
|
# GPU series are named from the payload's own `gpus[].index`, never discovered/guessed
|
|
assert "gpu.busy_pct@" in js and "gpu.index" in js
|
|
# the GPU chart exists only while the host reports cards
|
|
gpu_slot = js[js.index("function hostChartSet("):js.index("function seriesFor(")]
|
|
assert re.search(r"if \(!wantsGpu && set\.gpu\)", gpu_slot), \
|
|
"a host that stops reporting GPUs keeps a GPU chart it has no data for"
|
|
|
|
pruner = js[js.index("function pruneHosts("):js.index("// ---- snapshot")]
|
|
assert "state.hosts.delete(name)" in pruner and "state.charts.delete(name)" in pruner, \
|
|
"a host that left the payload keeps a live panel and drawing charts"
|
|
|
|
|
|
def test_system_history_charts_are_collapsed_behind_a_toggle(client) -> None:
|
|
"""TODO.md L4: "The historical charts on the system resources page should only be visible
|
|
if expanded. I only want to see current stats to keep things clean."
|
|
|
|
The default `/system` view is the hero row. Pinned on the served script because the card
|
|
markup is generated in JS and there is no JS test runner (a locked anchor), and the two
|
|
ways this regresses are invisible otherwise: a `<details open>` (charts visible by
|
|
default, i.e. the task un-done but the page still pretty) and a paint pass that runs
|
|
while the stack is shut, which builds three `LineChart`s per host inside a
|
|
`display:none` container — measured 0 px wide, never drawn, and rebuilt every poll.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
card = js[js.index("function hostCard("):js.index("function memorySub(")]
|
|
|
|
# the stack is *wrapped*, not merely preceded by a heading: it lives inside the <details>
|
|
box = card.index('<details class="charts-box" data-k="charts">')
|
|
stack = card.index('<div class="host-charts">')
|
|
closed = card.index("</details>")
|
|
assert box < stack < closed, "the chart stack is not inside the toggle"
|
|
assert closed < card.index("More detail"), "the charts toggle swallowed the detail row too"
|
|
assert not re.search(r"<details[^>]*\bopen\b", card), \
|
|
"a panel ships its charts open — the default view is not current stats"
|
|
assert re.search(r"<summary[^>]*>\s*Charts", card), "the toggle no longer reads Charts"
|
|
# it also says which window the expanded stack draws (the chips stay in the page head)
|
|
assert 'data-k="charts-window"' in card, "the toggle does not carry the window"
|
|
# …and the toggle is what turns the paint pass on
|
|
assert "addEventListener('toggle'" in card and "chartsToggled(" in card
|
|
|
|
setter = js[js.index("function setChartsWindow("):js.index("function memorySub(")]
|
|
assert "state.rangeKey" in setter, \
|
|
"the toggle re-derives its window label instead of using the chips' own"
|
|
handler = js[js.index("Dash.rangeControl("):js.index("state.poller = poll(")]
|
|
assert "state.rangeKey = key" in handler and "setChartsWindow(" in handler, \
|
|
"changing the window leaves every toggle labelling the old one"
|
|
|
|
# nothing is built or drawn while the stack is shut, in either path that could paint
|
|
paint = js[js.index("function drawHostCharts("):js.index("async function renderHostCharts(")]
|
|
assert "if (!chartsOpen(card)) return" in paint, "a closed panel gets painted anyway"
|
|
assert "hostChartSet(card, host)" in paint, \
|
|
"the LineChart instances are no longer built by the open pass"
|
|
toggle = js[js.index("function chartsToggled("):js.index("function drawHostCharts(")]
|
|
assert "if (!chartsOpen(card)) return" in toggle, "closing the stack still paints"
|
|
assert "renderHostCharts(card, host)" in toggle and "state.range" in toggle, \
|
|
"a cached response from another window is drawn under this window's label"
|
|
|
|
# the request discipline is unchanged: one per host per refresh, every series named
|
|
fetch = js[js.index("async function renderHostCharts("):js.index("function windowLabel(")]
|
|
assert "hostChartSet(" not in fetch, "the poll path still builds charts for closed panels"
|
|
assert "state.history.set(" in fetch, \
|
|
"the response is not kept, so the first open is an empty box until the next poll"
|
|
assert "if (range === state.range) drawHostCharts(" in fetch, \
|
|
"a response for a window the operator just left is painted under the new label"
|
|
assert "drawHostCharts(" in fetch, "an open panel is no longer repainted on refresh"
|
|
|
|
|
|
def test_a_pruned_host_takes_its_charts_toggle_with_it(client) -> None:
|
|
"""A host that leaves the payload must leave no orphan control in the grid.
|
|
|
|
The toggle is a child of the panel, which is why `card.remove()` is enough — but that is
|
|
only true while the markup is built inside `hostCard()`, so the placement is pinned here
|
|
along with the three per-host Maps the panel owns.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
card = js[js.index("function hostCard("):js.index("function memorySub(")]
|
|
assert 'data-k="charts"' in card, "the toggle is no longer part of the panel"
|
|
pruner = js[js.index("function pruneHosts("):js.index("// ---- snapshot")]
|
|
assert "state.hosts.get(name).remove()" in pruner
|
|
for owned in ("state.charts", "state.payloads", "state.history"):
|
|
assert f"{owned}.delete(name)" in pruner, f"{owned} outlives the panel it belongs to"
|
|
|
|
|
|
def test_the_charts_toggle_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
|
"""The collapse adds no palette and no second vocabulary (UI conventions).
|
|
|
|
Open/closed is carried by a caret *shape* as well as a colour step, the summary is muted
|
|
rather than accented, and it introduces no `.sev-*` family — the same rule the hero
|
|
numbers live by, checked here because a `<summary>` is the first control on the page a
|
|
designer might be tempted to grade.
|
|
"""
|
|
css = client.get("/static/dash.css").text
|
|
block = css[css.index("details.charts-box { "):css.index(".host-charts {")]
|
|
assert "details.charts-box > summary::before" in block, "the toggle has no caret shape"
|
|
assert 'details.charts-box[open] > summary::before' in block, \
|
|
"open and closed differ only in colour"
|
|
assert "var(--text-faint)" in block, "the closed toggle is not muted"
|
|
assert ".sev-" not in block and "var(--accent)" not in block, "the toggle invented a palette"
|
|
assert 'class="charts-box"' in client.get("/static/page-system.js").text, \
|
|
"the stylesheet styles a class the page no longer renders"
|
|
|
|
|
|
def test_an_expanded_chart_stack_does_not_grow_its_collapsed_siblings(client) -> None:
|
|
"""One host open must not stretch the hosts beside it into blank cards.
|
|
|
|
`/system` is the same shape `/llm` had to fix: an expanded panel beside a collapsed one
|
|
grows the collapsed card to match, and a blank panel reads as "no data" (the misreading
|
|
the unconfigured rules exist to prevent). `.grid.items-start` already exists for exactly
|
|
this, so the page uses it rather than growing a rule of its own.
|
|
"""
|
|
html = _code(client.get("/system").text)
|
|
hosts_class = re.findall(r'<div class="([^"]*)" id="hosts">', html)
|
|
assert hosts_class == ["grid auto items-start"], \
|
|
f"the host grid stretches its cards again: {hosts_class}"
|
|
assert ".grid.items-start" in client.get("/static/dash.css").text, \
|
|
"the rule the host grid relies on is gone"
|
|
|
|
|
|
def test_every_system_chart_names_itself_for_assistive_tech(client) -> None:
|
|
"""A 120px SVG is silence to a screen reader unless the page names it.
|
|
|
|
The phase-03 accessibility pass (task 04) found the per-host charts drawing anonymous
|
|
plots: the legend is a sibling element and labels nothing, so the `<svg>` itself must
|
|
carry `role="img"` + `aria-label` that says which host and which metrics. Pinned on the
|
|
served bytes because there is no JS test runner (a locked anchor).
|
|
"""
|
|
charts_js = client.get("/static/charts.js").text
|
|
# the shared LineChart applies the name to the svg, and only when the caller opted in
|
|
assert "if (this.opts.ariaLabel)" in charts_js, "LineChart stopped honouring ariaLabel"
|
|
assert "setAttribute('role', 'img')" in charts_js
|
|
assert "setAttribute('aria-label', String(this.opts.ariaLabel))" in charts_js
|
|
|
|
js = client.get("/static/page-system.js").text
|
|
# all three slots name themselves, and each name starts with the host the panel belongs
|
|
# to — an aria-label that cannot say *which machine* is not an accessible name here
|
|
assert js.count("ariaLabel: `") == 3, "a per-host chart slot lost its accessible name"
|
|
for slot in ("utilisation", "network traffic", "GPU busy"):
|
|
assert f"${{host.name}} \u2014 {slot}" in js or f"${{host.name}} — {slot}" in js, \
|
|
f"the {slot} chart has no host-scoped accessible name"
|
|
|
|
|
|
# ---- event feeds: a bounded, paginated window ----------------------------- #
|
|
#
|
|
# Every `ul.events` feed is one component (`Dash.eventsPager`) because a pager that
|
|
# works on only one page is the bug this shape prevents: the `event` table grows for as long
|
|
# as the fleet runs, and an unbounded feed turns `/system` into a scroll to nowhere
|
|
# (`TODO.md` L6 — "All the events tables need to be scrollable and paginated"). There is no
|
|
# JS test runner (a locked anchor), so what is pinned here is the shape every feed must keep:
|
|
# one shared renderer, a fetch bigger than one page, a footer that counts and labels itself,
|
|
# and a container that scrolls instead of growing the page.
|
|
|
|
# page -> (its script, the `source=` the poller stores that page's events under)
|
|
EVENT_FEEDS = {
|
|
"/system": ("page-system.js", "system"),
|
|
"/disks": ("page-disks.js", "disks"),
|
|
"/llm": ("page-llm.js", "llm"),
|
|
"/ups": ("page-ups.js", "ups"),
|
|
}
|
|
|
|
|
|
def _event_feed_path(js: str) -> str:
|
|
"""Just the `refreshEvents()` half of a page script — the feed's whole code path, comments out."""
|
|
js = _code(js)
|
|
return js[js.index("async function refreshEvents("):]
|
|
|
|
|
|
def _pager_call(feed: str, selector: str) -> str:
|
|
"""The options object of one `Dash.eventsPager($('<selector>'), …)` call inside the feed path.
|
|
|
|
Sliced per list so an assertion can name *which* feed lost its renderer, its accessible name
|
|
or its empty sentence. `/ups` is the page with two of them, so a whole-file assertion there
|
|
cannot tell one list from the other.
|
|
"""
|
|
head = f"Dash.eventsPager($('{selector}')"
|
|
assert head in feed, f"this feed is no longer rendered through Dash.eventsPager ({selector})"
|
|
start = feed.index(head)
|
|
end = feed.find("});", start)
|
|
assert end != -1, f"the {selector} pager call is never closed"
|
|
return feed[start:end]
|
|
|
|
|
|
def _feed_fetch_url(js: str) -> str:
|
|
"""The `/api/events` URL a feed asks for, quoted or templated."""
|
|
found = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", _event_feed_path(js))
|
|
assert found, "this page no longer fetches the event feed"
|
|
return found.group(1)
|
|
|
|
|
|
def test_the_event_feeds_are_one_shared_pager_not_one_per_page(client) -> None:
|
|
"""`Dash.eventsPager()` renders every feed, and no page grows its own pager.
|
|
|
|
One copy per page is one chance per page to keep the old behaviour — and `/system` is the
|
|
one whose feed was already too tall when this was written down. `/disks` joined as a
|
|
fourth call site of the same helper, not as a fifth implementation of it.
|
|
"""
|
|
app_js = _code(client.get("/static/app.js").text)
|
|
assert app_js.count("function eventsPager(") == 1
|
|
exports = client.get("/static/app.js").text.rsplit("return {", 1)[1].split("}", 1)[0]
|
|
assert "eventsPager" in exports, "Dash.eventsPager is not exported"
|
|
|
|
for path, (script, _) in EVENT_FEEDS.items():
|
|
js = _code(client.get(f"/static/{script}").text)
|
|
assert "Dash.eventsPager($('#events')" in js, f"{path} does not render its feed through the pager"
|
|
assert "function eventsPager(" not in js, f"{path} invented its own pager"
|
|
assert "events.slice(" not in js, f"{path} windows its own events instead of asking the pager"
|
|
assert "row:" in _event_feed_path(js), f"{path} passes no row renderer"
|
|
|
|
|
|
def test_the_pager_pages_50_events_and_fetches_more_than_one_page(client) -> None:
|
|
"""The two numbers that make a client-side pager work, and the relation between them.
|
|
|
|
`EVENT_PAGE_SIZE` is the 50 the operator asked for; `EVENT_FEED_LIMIT` is what the feed
|
|
asks `/api/events` for. A limit at or below the page size is a pager that silently never
|
|
shows — there is never a second page to reach — and the feeds used to fetch 20/25/40.
|
|
"""
|
|
app_js = client.get("/static/app.js").text
|
|
page_size = re.search(r"const EVENT_PAGE_SIZE = (\d+);", app_js)
|
|
feed_limit = re.search(r"const EVENT_FEED_LIMIT = (\d+);", app_js)
|
|
assert page_size and int(page_size.group(1)) == 50, "a feed no longer pages 50 events"
|
|
assert feed_limit, "the feeds' event limit is no longer a named constant"
|
|
assert int(feed_limit.group(1)) > int(page_size.group(1)), \
|
|
"a fetch at or below one page means page 2 can never exist"
|
|
|
|
for path, (script, source) in EVENT_FEEDS.items():
|
|
url = _feed_fetch_url(client.get(f"/static/{script}").text)
|
|
assert "EVENT_FEED_LIMIT" in url, f"{path} hardcodes its own event limit"
|
|
assert f"source={source}" in url, f"{path} asks for the wrong event source"
|
|
|
|
|
|
def test_the_pager_footer_counts_and_labels_its_controls(client) -> None:
|
|
"""The footer counts (`1\u201350 of 137`), and prev/next are labelled and disabled at the ends.
|
|
|
|
The count is what tells the operator the window is a window; a control that stays live at
|
|
the last page claims there is more history than there is.
|
|
"""
|
|
app_js = _code(client.get("/static/app.js").text)
|
|
assert "${first}\u2013${last} of ${total}" in app_js, "the footer no longer counts"
|
|
assert 'data-pager="prev"' in app_js and 'data-pager="next"' in app_js
|
|
assert "prev.setAttribute('aria-label'" in app_js
|
|
assert "next.setAttribute('aria-label'" in app_js
|
|
assert "prev.disabled = pager.page === 0" in app_js
|
|
assert "next.disabled = pager.page >= pager.pages - 1" in app_js
|
|
# pagination only applies when there is more than one page of data
|
|
assert "foot.hidden = !paginating" in app_js
|
|
|
|
css = client.get("/static/dash.css").text
|
|
assert ".events-pager {" in css, "the pager footer has no style"
|
|
assert "button.ghost:disabled" in css, "a disabled pager control looks live"
|
|
|
|
|
|
def test_the_event_feeds_scroll_instead_of_growing_the_page(client) -> None:
|
|
"""The `<ul>` is the scrollable element, bounded in `dash.css`; the page stays short.
|
|
|
|
Pinned on the served bytes because there is no JS test runner (a locked anchor): a
|
|
stylesheet edit that drops `max-height` is invisible until the feed is long, and by then
|
|
the page is massive again.
|
|
"""
|
|
css = client.get("/static/dash.css").text
|
|
block = css[css.index("ul.events {"):css.index("}", css.index("ul.events {"))]
|
|
assert "max-height" in block and "overflow-y: auto" in block, \
|
|
"the event feed is unbounded again — the page grows with history"
|
|
|
|
for path in EVENT_FEEDS:
|
|
html = client.get(path).text
|
|
assert '<ul class="events" id="events"' in html, f"{path} lost its feed's id"
|
|
|
|
|
|
def _details_blocks(html: str) -> list[str]:
|
|
"""Every `<details>…</details>` block on a served page, in document order.
|
|
|
|
Nesting is not a thing on these pages (one disclosure per card), so a flat scan of pairs is
|
|
enough — and it is what lets a pin say *no disclosure contains this*, rather than only
|
|
"not the one disclosure I happen to know about". Phase 26 needs exactly that: the operator's
|
|
complaint was that a connection error was reachable only by opening something.
|
|
"""
|
|
blocks = []
|
|
for found in re.finditer(r"<details\b[^>]*>", html):
|
|
end = html.find("</details>", found.end())
|
|
assert end != -1, "a served <details> is never closed"
|
|
blocks.append(html[found.start():end])
|
|
return blocks
|
|
|
|
|
|
def test_ups_power_events_feed_sits_behind_a_collapsed_disclosure(client) -> None:
|
|
"""The `/ups` power feed is *expandable*: one `details.events-box`, shut by default.
|
|
|
|
`TODO.md:4` asked for an expandable list of incidents and phase 26 kept that shape — what it
|
|
overruled was *what* was behind it. A collapsed `<details>` renders no content, so the shut
|
|
card is still the resting state; since phase 26 it gates **visibility only** (the feed is
|
|
fetched on every poll, which the next test pins), and it holds only the power kinds.
|
|
|
|
Pinned on the served bytes because there is no JS test runner (a locked anchor): an `open`
|
|
attribute would undo the phase silently, the `#events` id has to stay *inside* the disclosure
|
|
or the pager and the bounded 320px scroll lose their target, and the **error** half
|
|
(`#events-errors`) has to be outside *every* disclosure and ship `hidden` rather than open —
|
|
a `NUT server NutError: cannot reach upsd …` line an operator has to click to read is the
|
|
bug this phase exists to fix (`TODO.md:6`).
|
|
"""
|
|
html = _code(client.get("/ups").text)
|
|
box = re.search(r'<details class="events-box">(.*?)</details>', html, re.S)
|
|
assert box, "the Power events card is no longer a details.events-box disclosure"
|
|
assert not re.search(r"<details[^>]*\bopen\b", html), \
|
|
"the disclosure ships open — the list is tall again, not expandable"
|
|
inner = box.group(1)
|
|
assert "<summary>Power events</summary>" in inner, "the summary lost its label"
|
|
assert '<ul class="events" id="events"' in inner, "the feed moved outside the disclosure"
|
|
assert "loading…" in inner, "the first-expand placeholder is gone"
|
|
assert "<h3>Power events</h3>" not in html, "the card heads its own body twice"
|
|
|
|
# The error half is on the page **and not collapsible**: no disclosure anywhere holds it.
|
|
# The card-local pin in `test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure`
|
|
# checks the card does not *contain* a `<details>`; this is the stronger direction — moving
|
|
# that card inside a new disclosure somewhere else would pass there and fail here.
|
|
assert 'id="events-errors"' in html, "the connection-error list left the served page"
|
|
assert not any('id="events-errors"' in block for block in _details_blocks(html)), \
|
|
"the error list is inside a disclosure — reading it needs a click again"
|
|
card_open = html[html.index('<section class="card" id="ups-errors-card"'):
|
|
html.index(">", html.index('<section class="card" id="ups-errors-card"'))]
|
|
assert "hidden" in card_open, \
|
|
"the error card's resting state is visible — an empty card on every quiet day"
|
|
|
|
# the rest of the page is untouched by the disclosure
|
|
for still_there in ('id="ups-cards"', 'id="chart-charge"', 'id="chart-load"',
|
|
'id="confirm-dialog"'):
|
|
assert still_there in client.get("/ups").text, f"/ups lost {still_there}"
|
|
|
|
|
|
# The event kinds `/ups` keeps behind the collapsed disclosure, and the writers that store
|
|
# them. `collectors.py` writes `status` (an `ups.status` transition, power loss included)
|
|
# and `test` (a self-test verdict); `api.py` writes `action` (a command this page sent).
|
|
# Everything else in the feed is a connection/error event (`unreachable` / `reachable` /
|
|
# `recording` / `store-error`) and belongs in the list that is never hidden.
|
|
UPS_POWER_EVENT_KINDS = ("status", "test", "action")
|
|
|
|
|
|
def _power_event_kinds(js: str) -> frozenset[str]:
|
|
"""The kinds `page-ups.js` keeps behind the disclosure, read out of its own set literal.
|
|
|
|
Parsing beats matching one exact line. The claim is *these three and only these* — a kind
|
|
added to the hidden list moves an operator-visible row out of sight, and a kind dropped from
|
|
it hides a power event — so an exact-string assert that happens to survive a reformat is not
|
|
the pin. An empty set is refused rather than returned, because it would make every event an
|
|
error row and look like a passing classification.
|
|
"""
|
|
found = re.search(r"const POWER_EVENT_KINDS = new Set\(\[(.*?)]\)", js, re.S)
|
|
assert found, "page-ups.js no longer defines POWER_EVENT_KINDS as a set literal of kind words"
|
|
kinds = re.findall(r"['\"]([^'\"]+)['\"]", found.group(1))
|
|
assert kinds, "POWER_EVENT_KINDS is empty — every event would land in the error list"
|
|
return frozenset(kinds)
|
|
|
|
|
|
def test_ups_events_are_fetched_on_every_poll(client) -> None:
|
|
"""Phase 19's lazy gate is out, and the feed splits into power events and errors.
|
|
|
|
`TODO.md:6` is not a complaint about the disclosure existing, it is a complaint about what
|
|
was behind it: a poll failure (`NUT server NutError: cannot reach upsd … (timed out after
|
|
4s)`) sat in the same collapsed list as the power events, so the one thing an operator
|
|
needs without opening anything was the one thing they had to open something for. The fix
|
|
is page-side and keyed on the stored `kind`: `status` / `test` / `action` stay behind the
|
|
disclosure, **everything else** is the always-visible error list — and because that list
|
|
is visible, `refresh()` fetches on every poll (a visible list that ages is worse than one
|
|
more 200-event fetch per interval). `/api/events` grew no vocabulary for any of this.
|
|
|
|
Pinned on the served script because there is no JS test runner (a locked anchor), and
|
|
pinned in both directions: the gate's absence *and* the kind set, so a silent rename on
|
|
the writer's side fails here instead of moving errors back into the hidden list.
|
|
"""
|
|
js = _code(client.get("/static/page-ups.js").text)
|
|
|
|
# The kind set, read out of the literal instead of matched as one line: exactly these three.
|
|
assert _power_event_kinds(js) == frozenset(UPS_POWER_EVENT_KINDS), \
|
|
(f"the hidden list's kind set is {sorted(_power_event_kinds(js))}, not "
|
|
f"{sorted(UPS_POWER_EVENT_KINDS)} — that classification is the operator's, not the page's")
|
|
|
|
# The lazy cache is gone: with an always-visible list there is nothing left to stamp.
|
|
decl = js[js.index("const state = {"):js.index("const MARKS")]
|
|
assert "events" not in decl, "the disclosure's lazy-fetch state came back"
|
|
assert "state.events" not in js, "something still reads the deleted state.events object"
|
|
|
|
# The poll path asks every interval, gated on nothing.
|
|
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
|
|
assert re.search(r"^\s*await refreshEvents\(\);", refresh, flags=re.M), \
|
|
"the poll loop no longer fetches the feed on every poll"
|
|
assert "state.events.open" not in refresh and "eventsBox" not in refresh, \
|
|
"the fetch is gated on the disclosure again"
|
|
|
|
# The toggle handler's fetch logic is gone with it: the native `<details>` now gates only
|
|
# visibility, so no `toggle` listener may exist to decide whether to fetch.
|
|
boot = js[js.index("document.addEventListener('DOMContentLoaded'"):]
|
|
assert "addEventListener('toggle'" not in boot, \
|
|
"a toggle handler is deciding whether to fetch again"
|
|
assert "eventsBox" not in boot, "the disclosure's fetch-on-expand handler came back"
|
|
|
|
# One fetch, two lists, split on `kind` — and the URL is the phase-19 one verbatim.
|
|
feed = _event_feed_path(js)
|
|
assert _feed_fetch_url(js) == "/api/events?limit=${Dash.EVENT_FEED_LIMIT}&source=ups", \
|
|
"the feed's fetch is no longer the unchanged /api/events call"
|
|
for vocabulary in ("kind=", "page=", "severity="):
|
|
assert vocabulary not in _feed_fetch_url(js), \
|
|
f"/api/events grew {vocabulary[:-1]} vocabulary for a page-side split"
|
|
assert "const power = data.events.filter((e) => POWER_EVENT_KINDS.has(e.kind));" in feed, \
|
|
"the power list is no longer the filter's positive arm"
|
|
assert "const errors = data.events.filter((e) => !POWER_EVENT_KINDS.has(e.kind));" in feed, \
|
|
"the error list is no longer the filter's default arm (an unknown kind must be visible)"
|
|
|
|
# Stale-last-good: the catch clause does *nothing*, so a poll that never answered leaves
|
|
# both `<ul>`s holding the rows they already had (the pager rewrites a list only when its
|
|
# markup changed — invariant 4's shape on the page side).
|
|
opened = "} catch (err) {"
|
|
at = feed.index(opened)
|
|
catch_body = feed[at + len(opened):feed.index("}", at + len(opened))].strip()
|
|
assert catch_body == "", \
|
|
f"the failure path does something ({catch_body!r}) instead of leaving the last good rows"
|
|
|
|
|
|
def test_the_ups_event_rows_keep_the_date_time_and_severity_shape(client) -> None:
|
|
"""`TODO.md:4`'s other half: an expandable list is only *clear* if each row says when.
|
|
|
|
Phase 19's disclosure and phase 26's split are the new parts; this is the half that had to
|
|
survive both untouched. Every row still opens with a `<time>` carrying a month/day plus a
|
|
24-hour clock, then the UPS name inside the `sev <severity>` chip the stylesheet colours from
|
|
the payload's own verdict, then the message through `eventText()` (subject named once) with
|
|
the runtime footnote only when the event actually carries one. Pinned on the served script
|
|
because there is no JS test runner (a locked anchor): a "tidy-up" of the row template that
|
|
drops the timestamp, prints the raw message, or escapes nothing would ship silently.
|
|
|
|
Since phase 26 task 02 the renderer is **one const read by both lists**, so the two halves
|
|
of one feed cannot grow two shapes — which is what this reads, rather than a `row:` option
|
|
that could differ per list.
|
|
"""
|
|
js = _code(client.get("/static/page-ups.js").text)
|
|
feed = _event_feed_path(js)
|
|
at = js.index("const eventRow = (event) =>")
|
|
row = js[at:js.index("async function refreshEvents(", at)]
|
|
|
|
# the date/time: `<time>` first, month + day + hour + minute, 24-hour (the operator reads
|
|
# an incident log in the order it happened, and "14:05" on its own is not that)
|
|
assert "<time>" in row and "</time>" in row, "the row lost its timestamp element"
|
|
assert "new Date(event.ts * 1000).toLocaleString(" in row, \
|
|
"the row formats something other than the event's own timestamp"
|
|
for option in ("month: 'short'", "day: 'numeric'", "hour: '2-digit'", "minute: '2-digit'",
|
|
"hour12: false"):
|
|
assert option in row, f"the timestamp no longer asks for {option}"
|
|
assert row.index("<time>") < row.index('class="sev'), "the row no longer leads with the date"
|
|
|
|
# who: the host inside the verdict chip, escaped, and the severity is the API's word
|
|
assert 'class="sev ${event.severity}"' in row, \
|
|
"the row stopped taking its colour word from the stored severity"
|
|
assert "${DashCharts.escapeHtml(event.host)}</span>" in row, \
|
|
"the row no longer names the UPS it belongs to, escaped"
|
|
|
|
# what: the message through the shared subject-said-once cleaner, and escaped
|
|
assert "${DashCharts.escapeHtml(eventText(event))}" in row, \
|
|
"the message is no longer cleaned and escaped (raw HTML from an upstream is a bug)"
|
|
|
|
# the runtime footnote is conditional: an event with no runtime says nothing about one
|
|
assert "event.data && event.data.runtime_seconds" in row, \
|
|
"the runtime footnote is no longer gated on the event carrying a runtime"
|
|
assert "duration(event.data.runtime_seconds)} runtime)" in row, \
|
|
"the runtime footnote stopped printing the stored seconds as a duration"
|
|
|
|
# and the renderer is still handed to the shared pager over the bounded list, labelled.
|
|
# Per **call**, not per file: `#events-errors` is a list of its own now, and the claim is
|
|
# that it is drawn, named and emptied exactly like the power list — a count of `row:` in the
|
|
# file would still pass if one call lost its renderer to a default.
|
|
for selector, label, empty in (("#events", "Power events", "No power events yet"),
|
|
("#events-errors", "Connection & errors",
|
|
"No connection errors")):
|
|
call = _pager_call(feed, selector)
|
|
assert "row: eventRow," in call, f"the {selector} list is no longer drawn by the shared row"
|
|
assert f"label: '{label}'" in call, f"the {selector} list lost its accessible name"
|
|
assert empty in call, f"the {selector} list no longer says which feed is empty"
|
|
|
|
# one renderer, two lists: a template the two lists do not share would be two shapes for one
|
|
# event, and the row may not branch on which list it landed in — the only split is the kind set
|
|
assert js.count("const eventRow = (event) =>") == 1, \
|
|
"the /ups lists no longer share one row template"
|
|
assert "event.kind" not in row and "POWER_EVENT_KINDS" not in row, \
|
|
"the row template reads the kind — the two lists are growing two shapes"
|
|
|
|
# The card's visibility is the one state this page claims about a list it cannot see, it is
|
|
# decided by the **error** half alone, and only after both lists are painted (earlier would
|
|
# flash the `loading…` seed on a first fetch with no error rows). The markup's own `hidden`
|
|
# default is pinned in test_ups_power_events_feed_sits_behind_a_collapsed_disclosure.
|
|
hidden_at = feed.index("$('#ups-errors-card').hidden")
|
|
assert feed[:hidden_at].count("Dash.eventsPager(") == 2, \
|
|
"the card's visibility is decided before both lists are drawn"
|
|
assert feed[hidden_at:feed.index(";", hidden_at)].endswith("= errors.length === 0"), \
|
|
"the error card's visibility no longer follows the error half of the feed"
|
|
|
|
|
|
def test_the_ups_connection_errors_are_a_card_of_their_own_above_the_disclosure(client) -> None:
|
|
"""Phase 26 task 02: the error half of the feed is a card, not a second list in a `<details>`.
|
|
|
|
`TODO.md:6` is that a `NUT server NutError: cannot reach upsd …` line is not something to
|
|
open a summary for. So the error rows get their own `section.card` **above** the Power
|
|
events disclosure, built from the vocabulary every other card on this page uses
|
|
(`.card > header`, `.card > .body`, `ul.events`, the pager's strip) — **no new stylesheet
|
|
selector**, because a second style for a second list is a second thing to keep in sync with
|
|
the bounded 320px scroll and the `1\u201350 of N` footer.
|
|
|
|
`hidden` is the resting state in the markup and the script keeps it true while the feed has
|
|
no non-power kinds: a permanent empty "Connection & errors" card is exactly the ink the
|
|
page's empty-state rules exist to avoid, and the first `unreachable` row is what shows it.
|
|
"""
|
|
html = _code(client.get("/ups").text)
|
|
|
|
card_at = html.index('<section class="card" id="ups-errors-card"')
|
|
card_end = html.index("</section>", card_at)
|
|
card = html[card_at:card_end]
|
|
opening = html[card_at:html.index(">", card_at)]
|
|
assert "hidden" in opening, "the error card ships visible with nothing in it"
|
|
assert '<ul class="events" id="events-errors"' in card, "the error card has no bounded feed"
|
|
assert "<h3>Connection & errors</h3>" in card, "the card lost its title"
|
|
# the whole point: this list is *not* behind a summary
|
|
assert "<details" not in card, "the error list is inside a disclosure again"
|
|
assert card_end < html.index('<details class="events-box">'), \
|
|
"the error card sits below the power-events disclosure it is meant to lead"
|
|
assert 'id="events"' in html[html.index('<details class="events-box">'):], \
|
|
"the power list left its disclosure"
|
|
|
|
js = _code(client.get("/static/page-ups.js").text)
|
|
feed = _event_feed_path(js)
|
|
assert "Dash.eventsPager($('#events-errors'), errors, {" in feed, \
|
|
"the error list is not rendered through the shared pager"
|
|
assert "label: 'Connection & errors'" in feed, "the error list has no accessible name"
|
|
assert "$('#ups-errors-card').hidden = errors.length === 0;" in feed, \
|
|
"the error card no longer appears and disappears with its rows"
|
|
# and the pager state is per `<ul>` (`app.js`: a `WeakMap` keyed by the list), so the two
|
|
# lists page independently: this is the contract that makes a second call site free
|
|
app_js = _code(client.get("/static/app.js").text)
|
|
assert "const feedPagers = new WeakMap();" in app_js
|
|
assert "feedPagers.get(list)" in app_js and "feedPagers.set(list, pager)" in app_js
|
|
|
|
|
|
def test_the_ups_error_card_adds_no_stylesheet_selector(client) -> None:
|
|
"""The second feed is markup + the existing vocabulary, and `dash.css` must stay that way.
|
|
|
|
Pinned as the *absence* of a rule, because the temptation this writes back into is a
|
|
`#ups-errors-card` block restating the feed's scroll or the pager's strip: `ul.events {` and
|
|
`.events-pager {` are each declared exactly once in the sheet (the disclosure pin already
|
|
holds that), and the error card carries its own gap as an inline `margin-top` like every
|
|
other section on this page rather than through an id rule.
|
|
"""
|
|
css = _css(client)
|
|
for selector in ("#ups-errors-card", "#events-errors"):
|
|
assert selector not in css, f"dash.css grew a rule for {selector}"
|
|
assert css.count("ul.events {") == 1 and css.count(".events-pager {") == 1
|
|
|
|
html = client.get("/ups").text
|
|
assert 'id="ups-errors-card" style="margin-top:.85rem"' in html, \
|
|
"the error card no longer carries the page's own section gap inline"
|
|
|
|
|
|
def test_the_ups_events_disclosure_is_a_quiet_disclosure_not_a_new_component(client) -> None:
|
|
"""The `/ups` summary borrows the card header's rhythm and no new palette (task 03).
|
|
|
|
Pinned on the served stylesheet because a CSS edit is invisible until someone looks: the
|
|
`\\25B8`/`\\25BE` caret pair says open/closed in a **shape** as well as a colour, the webkit
|
|
marker is hidden so the browser's own triangle does not double it, and this `<summary>`
|
|
carries the card header's ground and bottom rule because it *replaces* that header instead of
|
|
sitting inside the body like `charts-box`'s toggle does. The `> .body` padding restatement is
|
|
the quiet one: `.card > .body` is a **child** selector, so a body moved inside a disclosure
|
|
silently loses its padding and the feed renders flush to the card edge.
|
|
|
|
What the block must not do is grow a verdict colour (open/closed is not a grading) or
|
|
re-state the shared feed rules — `ul.events`' bounded scroll and `.events-pager`'s strip are
|
|
class-based and resolve inside the disclosure unchanged, so a second copy here would be a
|
|
second thing to keep in sync. The undeclared-token audit itself stays the existing
|
|
`test_no_stylesheet_rule_uses_an_undeclared_token`'s job, untouched.
|
|
"""
|
|
css = _css(client)
|
|
start = css.index("details.events-box { ")
|
|
block = css[start:css.index(".host-charts {", start)]
|
|
|
|
assert 'details.events-box > summary::before { content: "\\25B8"' in block, \
|
|
"the toggle has no caret shape"
|
|
assert 'details.events-box[open] > summary::before { content: "\\25BE"; }' in block, \
|
|
"open and closed differ only in colour"
|
|
assert "details.events-box > summary::-webkit-details-marker { display: none; }" in block, \
|
|
"the browser's own marker doubles the caret"
|
|
assert "cursor: pointer" in block and "list-style: none" in block, \
|
|
"the summary no longer reads as a control"
|
|
# it replaces the card header, so it carries the header's ground and bottom rule
|
|
assert "border-bottom: 1px solid var(--border)" in block, \
|
|
"a closed card no longer reads as its heading"
|
|
assert "color-mix(in srgb, var(--bg-raised) 70%, var(--bg-sunken))" in block, \
|
|
"the summary lost the header's sunken ground"
|
|
# open: the label steps back, and no margin — the `.body` below carries the spacing
|
|
assert "details.events-box[open] > summary { color: var(--text-faint); }" in block, \
|
|
"an open summary does not de-emphasise"
|
|
assert "margin-bottom" not in block, "the open summary stacks a gap on top of the body's"
|
|
# the child-selector gotcha that task 01's markup introduced
|
|
assert "details.events-box > .body { padding: .8rem; }" in block, \
|
|
"the feed body lost its padding inside the disclosure"
|
|
|
|
for token in ("var(--ok)", "var(--warn)", "var(--fail)", "var(--accent)"):
|
|
assert token not in block, f"the disclosure graded open/closed with {token}"
|
|
assert ".sev-" not in block, "the disclosure invented a verdict family"
|
|
|
|
# one copy of each shared feed rule, class-based, resolving inside the disclosure as elsewhere
|
|
assert css.count("ul.events {") == 1, "ul.events is styled twice — the bounded scroll drifted"
|
|
assert css.count(".events-pager {") == 1, "the pager strip is styled twice"
|
|
# the class this block styles is the class the page renders
|
|
assert 'class="events-box"' in client.get("/ups").text, \
|
|
"the stylesheet styles a disclosure no page renders"
|
|
|
|
# Phase 26's guard, here because it is the disclosure's own claim: the *second* feed this
|
|
# page renders borrowed `section.card` + `ul.events` + `.events-pager` and nothing else, so
|
|
# this block may not have grown a rule for it either. A second style for a second list is a
|
|
# second copy of the bounded scroll and the pager strip to keep in sync — the phase-19
|
|
# anti-pattern, one task later. (`test_the_ups_error_card_adds_no_stylesheet_selector` holds
|
|
# the same guard for the card's own section, plus the inline-gap pin.)
|
|
for selector in ("#ups-errors-card", "#events-errors"):
|
|
assert selector not in block and selector not in css, \
|
|
f"dash.css styles the second feed through {selector} instead of the shared classes"
|
|
|
|
|
|
def test_the_pager_returns_to_page_1_when_the_events_change(client) -> None:
|
|
"""New events reset the window; the same events do not.
|
|
|
|
Resetting on every poll would snap `next` back within one interval, so page 2 could never
|
|
be read; never resetting lands an operator on page 3 of a list that changed underneath
|
|
them. The page key is the set of events, not the clock.
|
|
"""
|
|
app_js = _code(client.get("/static/app.js").text)
|
|
body = app_js[app_js.index("function eventsPager("):]
|
|
assert "signature !== pager.signature" in body, "the pager no longer notices new events"
|
|
reset = body[body.index("signature !== pager.signature"):]
|
|
assert reset.index("pager.page = 0") < reset.index("paintEventFeed"), \
|
|
"new events do not reset the page before the feed is drawn"
|
|
assert "if (pager.markup !== markup)" in app_js, \
|
|
"every poll rewrites 50 rows and throws away the reader's scroll position"
|
|
|
|
|
|
def test_system_event_feed_queries_the_source_transitions_are_stored_under(client) -> None:
|
|
"""Host transitions are stored under the *dashboard* name, not the sample source.
|
|
|
|
`poller.py` logs `Event(ts, self.dashboard, …)` — host jobs write `system`, the way
|
|
the UPS job writes `ups` — while *samples* use `host`. The `/system` feed queried
|
|
`source=host` since the first commit and therefore always rendered "no transitions
|
|
yet", even with a real outage (a gateway disconnect) sitting in the store. Pinned on
|
|
the served bytes because there is no JS test runner (a locked anchor).
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
feed = _event_feed_path(js)
|
|
url = re.search(r"fetchJSON\([`'](/api/events[^`']*)[`']\)", feed)
|
|
assert url, "the /system page no longer fetches the event feed"
|
|
assert "source=system" in url.group(1), \
|
|
"the /system event feed must ask for the source the poller stores events under"
|
|
assert "source=host" not in url.group(1), \
|
|
"source=host matches no stored event — the feed is empty forever"
|
|
|
|
|
|
def test_system_footnote_prints_the_window_it_actually_measured(client) -> None:
|
|
"""`Δ<window>` is the page's promise that a rate is a measurement (invariant 2), so the
|
|
number behind it has to come from the measurement.
|
|
|
|
The `window_seconds` beside the fleet is `HOST_INTERVAL_SECONDS`, while the window behind a
|
|
rate is the estimator's `RATE_WINDOW_SECONDS` — equal at the defaults, which is why the
|
|
footnote could quote the poll interval and look right. Set `RATE_WINDOW_SECONDS=30` and it
|
|
would have labelled every rate on the page with a window it never used. The footnote now
|
|
reads each host's own measured `window_seconds`, and prints a range when they disagree.
|
|
"""
|
|
js = client.get("/static/page-system.js").text
|
|
assert "function windowLabel(" in js, "the footnote lost its measured-window helper"
|
|
footnote = js[js.index("$('#foot-note').textContent"):js.index("await Promise.all(chartJobs)")]
|
|
assert "windowLabel(" in footnote
|
|
assert "host.window_seconds" in footnote, \
|
|
"the footnote is quoting the poll interval as a measurement again"
|
|
# a footnote that says "window" and a page that says "poll" must stay two separate claims
|
|
assert "poll ${Math.round(state.pollMs / 1000)}s" in footnote
|
|
|
|
|
|
def test_no_per_core_visual_library_survives_the_page(client) -> None:
|
|
"""The per-core heat bars left `/system` and `charts.js` with them (PLAN.md *Dropped
|
|
visuals* is about what the pages draw, not what the store keeps).
|
|
|
|
`renderCores` had one caller — the hero-card per-core block deleted in task 02 — so
|
|
keeping it would be dead markup generating unstyled `.core` spans; `.cores` was the CSS
|
|
that block needed. Both are checked absent here so neither can come back as a
|
|
half-change, while `sparkline`/`LineChart` stay for phase 04 and the UPS page.
|
|
"""
|
|
assert "renderCores" not in client.get("/static/charts.js").text
|
|
css = client.get("/static/dash.css").text
|
|
assert ".cores" not in css
|
|
for asset in ASSETS:
|
|
assert "core_usage" not in _code(client.get(asset).text), f"{asset} plots per-core"
|
|
# what phase 04 and the other pages still chart from
|
|
for name in ("LineChart", "sparkline", "renderLegend"):
|
|
assert name in client.get("/static/charts.js").text
|
|
|
|
|
|
def test_sparkline_ships_the_fixed_scale_and_verdict_options(client) -> None:
|
|
"""Phase 04 turns `/llm` cells into sparkline rows, so the shared `sparkline()` must
|
|
behave like `llm_monitor`'s `drawSpark` (the task-02 contract, NOTES-chart-set).
|
|
|
|
Pinned on the served bytes because there is no JS test runner (a locked anchor). What
|
|
matters is not the option names but the semantics behind each one: a percent metric
|
|
that silently rescaled to its own data would make an 8 % KV cache and a 92 % spike the
|
|
same squiggle, and a stroke coloured from a page-invented threshold would break the
|
|
one-colour-language rule the same way.
|
|
"""
|
|
charts_js = client.get("/static/charts.js").text
|
|
body = charts_js[charts_js.index("function sparkline("):charts_js.index("window.DashCharts")]
|
|
|
|
# the phase grep criterion, as a test: the options are really in the shipped file
|
|
hits = [ln for ln in charts_js.splitlines()
|
|
if re.search(r"fixedMax|align|severity", ln)]
|
|
assert len(hits) >= 3, "the sparkline upgrade is not in the served charts.js"
|
|
for opt in ("fixedMax", "align", "labels", "severity", "minPoints", "span", "format",
|
|
"minSpan"):
|
|
assert opt in body, f"sparkline() lost its `{opt}` option"
|
|
|
|
# fixedMax: the data clamps to the axis, never the axis to the data
|
|
assert "Math.min(Math.max(v, lo), hi)" in body, \
|
|
"a fixedMax sparkline rescales to its own data again"
|
|
# align:'right' grows within a real time window (span seconds), not a sample count
|
|
assert "x1 - o.span" in body, "align:'right' forgot the window it grows inside"
|
|
# verdict colour comes from the theme tokens only; unrated is faint grey, never green
|
|
for token in ("cssVar('--ok')", "cssVar('--warn')", "cssVar('--fail')",
|
|
"cssVar('--text-faint')"):
|
|
assert token in body, f"sparkline() stopped taking its colour from {token}"
|
|
assert ".sev-" not in body, "sparkline invented a parallel severity vocabulary"
|
|
# the `now / max` foot llm_monitor prints, via the caller's format callback
|
|
assert "now ${fmt(last.value)}" in body and "max ${fmt(dataMax)}" in body
|
|
# never a confident line through one point, and the foot quotes data, not the padded axis
|
|
assert "minPoints: 2" in body and "no history" in body
|
|
# ASSUMPTION (task 02): no animation at all, so prefers-reduced-motion holds by design
|
|
assert "transition:" not in body and "animation:" not in body
|
|
|
|
|
|
def test_a_flat_series_keeps_the_axis_floor_the_page_declared(client) -> None:
|
|
"""`_yExtent()` pads a flat series so its line has a band to sit in, and a declared `yMin`
|
|
is not negotiable inside that padding.
|
|
|
|
Every idle gateway pipe publishes `0`, so the first `/gateway` charts were flat and the old
|
|
`lo === hi` shortcut answered `[lo - 1, hi + 1]` whatever the page passed: the axis grew a
|
|
`-1` tick, which is a scale describing a number of connections that cannot exist — the same
|
|
mistake as printing `0` where nothing measured, wearing an axis instead of a cell.
|
|
"""
|
|
charts = _code(client.get("/static/charts.js").text)
|
|
extent = charts[charts.index("_yExtent()"):charts.index(" draw()")]
|
|
assert "this.opts.yMin !== null ? Math.min(lo, this.opts.yMin) : lo - pad" in extent, \
|
|
"a flat series is padding below the floor the page declared"
|
|
|
|
|
|
# ---- the battery glyph (phase 05 task 02) ---------------------------------
|
|
#
|
|
# `DashCharts.battery()` is the one visual in this app whose *shape* is the data: the fill
|
|
# is the charge. That makes the honesty rules heavier here than anywhere else — a fill
|
|
# rounded up to look reassuring, a colour invented for an unrated cell, or a state only
|
|
# readable as green-vs-red would each turn a glance at the battery into a wrong claim.
|
|
# There is no JS test runner (a locked anchor), so the construction is pinned on the served
|
|
# bytes and shown in a browser (`data/logs/ups-glyph-*.png`).
|
|
|
|
def _battery_js(client: TestClient) -> str:
|
|
"""The glyph's code as served (state table + body), comments stripped.
|
|
|
|
Prose cannot satisfy these assertions, and the phrase table the accessible name is
|
|
built from is part of the component, so the slice starts above `function battery`.
|
|
"""
|
|
charts = client.get("/static/charts.js").text
|
|
body = charts[charts.index("const BATTERY_STATES"):charts.index("window.DashCharts")]
|
|
assert "function battery(container, opts)" in body
|
|
return _code(body)
|
|
|
|
|
|
def test_battery_glyph_is_exported_from_the_shared_chart_library(client) -> None:
|
|
"""The phase's own grep criterion, as a test: exactly one glyph, and it is public."""
|
|
charts = client.get("/static/charts.js").text
|
|
assert charts.count("function battery") == 1
|
|
exports = charts[charts.index("window.DashCharts"):]
|
|
assert re.search(r"\bbattery\b", exports), "DashCharts.battery is not exported"
|
|
# the wrapper the stylesheet sizes, added by the component rather than each caller
|
|
assert ".battery {" in _css(client) and ".battery svg" in _css(client)
|
|
|
|
|
|
def test_battery_fill_is_the_charge_and_is_never_padded(client) -> None:
|
|
"""2 % must look nearly empty, and 0 % must leave an empty shell, not a stray pixel."""
|
|
body = _battery_js(client)
|
|
# clamped to the scale, and the clamp is the only thing that touches the number: a
|
|
# minimum visible width would be a reassuring battery over a flat one
|
|
assert "Math.max(0, Math.min(100," in body
|
|
assert "(pctValue / 100) * inner.w" in body, "the fill stopped being a fraction of charge"
|
|
assert not re.search(r"Math\.max\(\s*[0-9.]*[1-9]\s*,", body), \
|
|
"the charge fill got a minimum width — a small charge would look bigger than it is"
|
|
# nothing is drawn for a cell at zero, and the plate it fills is a clip, so a low fill
|
|
# cannot leak outside the rounded interior
|
|
assert "pctValue > 0" in body
|
|
assert "<clipPath" in body and 'clip-path="url(#' in body
|
|
# an empty cell is the sunken plate, never a transparent hole in the card
|
|
assert "cssVar('--bg-sunken')" in body
|
|
|
|
|
|
def test_battery_colours_come_from_the_verdict_tokens_only(client) -> None:
|
|
"""The verdict paints; an unrated cell draws a grey shape and no colour at all."""
|
|
body = _battery_js(client)
|
|
for token in ("cssVar('--ok')", "cssVar('--warn')", "cssVar('--fail')"):
|
|
assert token in body, f"the glyph stopped taking its colour from {token}"
|
|
# no verdict → muted outline, and a measured charge in faint grey at half strength: a
|
|
# shape, not a judgement (an unknown number must never look green)
|
|
assert "cssVar('--text-muted')" in body and "cssVar('--text-faint')" in body
|
|
assert ' opacity=".5"' in body, "an unrated charge is drawn with the same weight as a rated one"
|
|
assert ".sev-" not in body, "the glyph invented a parallel severity vocabulary"
|
|
assert "severityFor(" not in body, "the glyph compares the charge to a threshold itself"
|
|
|
|
|
|
def test_battery_states_are_readable_without_colour(client) -> None:
|
|
"""Every state has a *shape*, so the glyph survives monochrome and colour-blindness.
|
|
|
|
on battery = dashed outline, charging = bolt, unknown = a dash pattern through the
|
|
body, online = a plain solid shell. A word the page did not intend falls back to
|
|
`unknown` rather than to a confident "on line".
|
|
"""
|
|
body = _battery_js(client)
|
|
assert 'BATTERY_STATES.indexOf(o.state) >= 0' in body, \
|
|
"an unrecognised state would be drawn as some other state"
|
|
assert 'state === \'onbattery\'' in body and 'stroke-dasharray="7 5"' in body, \
|
|
"the on-battery outline is no longer dashed"
|
|
assert "state === 'charging'" in body and 'd="M13 2 L3 14' in body, "the charging bolt is gone"
|
|
assert "state === 'unknown'" in body and 'stroke-dasharray="5 6"' in body, \
|
|
"an unknown charge no longer draws a dash through the body"
|
|
# and the unknown case really is *no fill*: the shape has to say it on its own
|
|
assert "pctValue != null" in body
|
|
|
|
|
|
def test_battery_glyph_names_itself_and_can_say_it_once(client) -> None:
|
|
"""`role="img"` + `<title>` built from the inputs, with an explicit `aria-hidden` opt-out."""
|
|
body = _battery_js(client)
|
|
assert 'role="img"' in body and "<title>" in body and "aria-label=" in body
|
|
for phrase in ("Charge ", " percent", "Charge unknown", "on line", "on battery",
|
|
"charging", "state unknown"):
|
|
assert phrase in body, f"the accessible name lost {phrase!r}"
|
|
# the label is a UPS name from upsd, so it goes through the one escaper
|
|
assert body.count("escapeHtml(name)") == 2, "<title> or aria-label stopped escaping the label"
|
|
assert 'aria-hidden="true"' in body, "the decorative opt-out for a duplicate hero number is gone"
|
|
|
|
|
|
def test_battery_glyph_scales_with_its_container_and_never_animates(client) -> None:
|
|
"""One `viewBox` + `preserveAspectRatio` for a phone and a 27" monitor; no motion at all."""
|
|
body = _battery_js(client)
|
|
assert 'viewBox="0 0 ${W} ${H}"' in body
|
|
assert 'preserveAspectRatio="xMidYMid meet"' in body, "the glyph stopped scaling with its box"
|
|
assert "ResizeObserver" not in body, "the glyph redraws on resize instead of scaling"
|
|
assert "transition" not in body and "animation" not in body
|
|
css = _code(_css(client))
|
|
wrapper = re.search(r"\.battery \{([^}]*)\}", css)
|
|
inner = re.search(r"\.battery svg \{([^}]*)\}", css)
|
|
assert wrapper and inner, "dash.css no longer sizes the glyph box"
|
|
assert "width: 100%" in wrapper.group(1) and "max-width" in wrapper.group(1), \
|
|
"the glyph is not capped inside its container"
|
|
assert "width: 100%" in inner.group(1) and "height: auto" in inner.group(1), \
|
|
"the svg would stretch instead of keeping the viewBox ratio"
|
|
both = wrapper.group(1) + inner.group(1)
|
|
assert "transition" not in both and "animation" not in both
|
|
|
|
|
|
# ---- the `/ups` hero row (phase 05 task 03) ------------------------------
|
|
#
|
|
# The card used to be nine equal `.metric` cells in which charge, load and runtime were the
|
|
# same size as the battery amperage. These pin the shape that replaced it on the served
|
|
# bytes (a locked anchor: no JS test runner), because the whole point of the row is a size
|
|
# and colour hierarchy a later "tidy up" can quietly flatten back to nine equal cells.
|
|
|
|
def _ups_card_js(client: TestClient) -> str:
|
|
"""The card markup as served, from `upsCard()` to `renderCard()`."""
|
|
js = client.get("/static/page-ups.js").text
|
|
return js[js.index("function upsCard("):js.index("function renderCard(")]
|
|
|
|
|
|
def test_ups_card_opens_with_the_glyph_and_three_heroes(client) -> None:
|
|
"""Charge / Load / Runtime are the card's three largest elements, next to the glyph.
|
|
|
|
`data-sev` names the verdict path each slot colours itself from, the same convention the
|
|
`/system` host card uses — so "which number is coloured by what" is readable in the
|
|
markup, and a rename that disconnects the page from `/api/ups/snapshot` fails here
|
|
instead of shipping a card of `n/a`.
|
|
"""
|
|
card = _ups_card_js(client)
|
|
assert '<div class="ups-hero">' in card, "the hero row left the top of the card"
|
|
assert 'data-k="glyph"' in card, "the battery glyph is not part of the hero row"
|
|
slots = re.findall(r'<div class="hero[^"]*" data-k="([^"]+)"', card)
|
|
assert slots == ["charge", "load", "runtime"], \
|
|
"the hero row is no longer charge / load / runtime in the agreed order"
|
|
for hook in ("severity.charge_pct", "severity.load_pct", "severity.battery_runtime"):
|
|
assert hook in card, f"the card no longer names {hook} as a colour source"
|
|
# the hero row is the first thing in the card body, above the health checks and commands
|
|
assert card.index("ups-hero") < card.index('data-k="checks"')
|
|
assert card.index("ups-hero") < card.index('data-k="commands"')
|
|
|
|
|
|
def test_ups_heroes_take_their_colour_from_the_payload(client) -> None:
|
|
"""Three `Dash.hero()` calls, three payload verdicts, no JS threshold.
|
|
|
|
The charge hero carries **no** `.bar`: the glyph beside it is that same value drawn as a
|
|
shape, and one value drawn twice in one row is the pattern this app avoids (load, which
|
|
has no other shape, keeps its bar). Runtime prose comes from `runtimeText()` alone, and
|
|
a runtime nobody estimated is passed as `null` so `hero()` renders the faint, uncoloured
|
|
unknown rather than the string `n/a` styled like a measurement.
|
|
"""
|
|
js = client.get("/static/page-ups.js").text
|
|
body = js[js.index("function renderCard("):js.index("// ---- command dialog")]
|
|
assert body.count("Dash.hero(q(") == 3, "the hero row is not three hero numbers"
|
|
for verdict in ("verdicts.charge_pct", "verdicts.load_pct", "verdicts.battery_runtime"):
|
|
assert verdict in body, f"{verdict} never reaches a hero"
|
|
charge = body[body.index("Dash.hero(q('charge')"):body.index("Dash.hero(q('load')")]
|
|
assert "bar" not in charge, "charge grew a second shape when the glyph already is one"
|
|
load = body[body.index("Dash.hero(q('load')"):body.index("Dash.hero(q('runtime')")]
|
|
assert "bar: true" in load, "load lost its bar"
|
|
assert "runtimeText(n.battery_runtime)" in body, "runtime prose bypasses runtimeText()"
|
|
runtime = body[body.index("Dash.hero(q('runtime')"):]
|
|
assert "n.battery_runtime == null ? null" in runtime, \
|
|
"an unmeasured runtime is rendered as a measurement"
|
|
assert "severityFor(" not in body
|
|
|
|
|
|
def test_ups_secondary_electrical_row_is_demoted_not_deleted(client) -> None:
|
|
"""Line in, output, battery volts and amps, battery °C: still there, visibly second.
|
|
|
|
These are the numbers you reach for *while* the UPS is on battery, so they were demoted
|
|
rather than folded into the disclosure — and they stay one step down (smaller, muted) so
|
|
the three verdicts remain the largest elements, which is also what keeps the card from
|
|
growing so tall that two stop fitting side by side on a laptop.
|
|
"""
|
|
card = _ups_card_js(client)
|
|
assert 'class="metrics ups-electrical"' in card
|
|
for key in ("line", "out", "batt", "amps", "temp"):
|
|
assert f'data-k="{key}"' in card, f"the electrical row lost {key}"
|
|
assert card.index("ups-electrical") > card.index("ups-hero")
|
|
css = _code(_css(client))
|
|
rule = re.search(r"\.ups-electrical \.metric \.value\.sm \{([^}]*)\}", css)
|
|
assert rule, "dash.css no longer styles the secondary row"
|
|
assert "var(--text-muted)" in rule.group(1), "the secondary row is not muted"
|
|
sizes = re.findall(r"font-size: ([\d.]+)rem", rule.group(1))
|
|
assert sizes and float(sizes[0]) < 1.3, "the secondary row competes with the heroes"
|
|
# `n/a` is fainter than a muted reading — neither real driver reports a battery temperature
|
|
null_rule = re.search(r"\.ups-electrical \.metric \.value\.sm\.is-null \{([^}]*)\}", css)
|
|
assert null_rule and "var(--text-faint)" in null_rule.group(1), \
|
|
"an unmeasured secondary value is not faint, so it reads like a number"
|
|
|
|
|
|
def test_ups_glyph_state_comes_from_the_api_fields(client) -> None:
|
|
"""`glyphState()` maps `on_battery` + `charger_status` — and `full` is **not** charging.
|
|
|
|
The mapping is presentation, so it lives in the page (task 02's assumption), but it is
|
|
still a mapping the real drivers exercise: neither UPS here exports
|
|
`battery.charger.status`, so the fallback that reads a state the API rated `ok` is what
|
|
draws a solid shell on both of them. Nothing here re-reads `ups.status` —
|
|
`test_the_page_does_not_re_parse_nut_status_flags` holds that line.
|
|
"""
|
|
js = _code(client.get("/static/page-ups.js").text)
|
|
glyph = js[js.index("function glyphState("):js.index("function drawGlyph(")]
|
|
assert "item.on_battery" in glyph and "'onbattery'" in glyph
|
|
assert "charger === 'chg'" in glyph and "'charging'" in glyph
|
|
assert "charger === 'dchg'" in glyph, "a discharging charger token is on battery too"
|
|
assert "charger === 'full'" in glyph and "'online'" in glyph, \
|
|
"a full, online battery must not be drawn as charging"
|
|
assert "item.states" in glyph, "a driver with no rich status lost its state entirely"
|
|
assert "'unknown'" in glyph, "an unknown state now degrades to a confident 'on line'"
|
|
|
|
|
|
def test_ups_glyph_repaints_only_when_something_visible_changes(client) -> None:
|
|
"""Charge jitters by a tenth of a percent every poll; the `<svg>` does not rebuild on it.
|
|
|
|
Rebuilding would re-resolve the theme tokens and re-announce the accessible name every
|
|
`NUT_INTERVAL_SECONDS` for a difference no eye can see. `decorative: true` is the
|
|
component's documented opt-out — the charge hero and the state chips already say
|
|
"Charge 100 percent, on line", and a screen reader should not hear it twice.
|
|
"""
|
|
js = _code(client.get("/static/page-ups.js").text)
|
|
draw = js[js.index("function drawGlyph("):js.index("function upsCard(")]
|
|
assert "DashCharts.battery(" in draw
|
|
assert "cell.dataset.drawn === drawn" in draw, "the glyph rebuilds on every poll"
|
|
assert "decorative: true" in draw, "the glyph duplicates the hero number in a11y"
|
|
assert "glyphState(item)" in draw
|
|
|
|
|
|
def test_ups_hero_row_makes_room_for_the_glyph(client) -> None:
|
|
"""The glyph gets a real column, and the row stacks rather than clipping on a phone.
|
|
|
|
The track floor is the widest runtime prose the page prints (`9h 59m`) — a number that
|
|
cannot wrap needs the floor to be its own width, or the row grows a clipped `1h 28`.
|
|
"""
|
|
css = _code(_css(client))
|
|
row = re.search(r"\.ups-hero \{([^}]*)\}", css)
|
|
assert row, "dash.css lost the UPS hero row"
|
|
assert "minmax(112px, 156px) minmax(0, 1fr)" in row.group(1), "the glyph has no column"
|
|
assert "align-items: center" in row.group(1)
|
|
heroes = re.search(r"\.ups-hero \.heroes \{([^}]*)\}", css)
|
|
assert heroes and "minmax(min(140px, 100%), 1fr)" in heroes.group(1), \
|
|
"a hero track can now clip the widest runtime prose"
|
|
at = css.index("@media (max-width: 620px)")
|
|
block = css[at:at + 240]
|
|
assert ".ups-hero" in block and "minmax(0, 1fr)" in block, "the row never stacks"
|
|
|
|
|
|
def test_every_ups_chart_names_itself_for_assistive_tech(client) -> None:
|
|
"""The four trend plots on `/ups` carry an accessible name, like every other chart here.
|
|
|
|
`AGENTS.md` states the convention as *every* chart names itself: `LineChart` puts
|
|
`role="img"` + `aria-label` on the `<svg>` when the caller passes `ariaLabel`, because
|
|
the legend is a sibling that labels nothing and an unlabelled plot is silence. `/system`
|
|
(phase 03) and `/llm` (phase 04) were pinned; these four were the stragglers, so the
|
|
rule is now pinned on every page that draws one.
|
|
"""
|
|
js = client.get("/static/page-ups.js").text
|
|
charts = js[js.index("function makeCharts("):js.index("function points(")]
|
|
assert charts.count("ariaLabel:") == 4, "a `/ups` chart lost its accessible name"
|
|
for phrase in ("Battery charge", "Battery runtime", "Battery and line voltage", "UPS load"):
|
|
assert phrase in charts, f"the {phrase!r} chart names nothing readable"
|
|
|
|
|
|
# ---- the `/llm` layout (phase 04 task 03) ---------------------------------
|
|
#
|
|
# The drift this phase fixes was a 14-cell grid where everything was the same size and
|
|
# nothing showed recent shape. These tests pin the *shape* the page committed to on the
|
|
# served bytes (a locked anchor: no JS test runner), so a regression to "all cells equal"
|
|
# fails a test rather than a screenshot.
|
|
|
|
def test_llm_page_colours_from_the_payload_not_from_js_thresholds(client) -> None:
|
|
"""`page-llm.js` paints the KV verdict `severity.py` produced; it owns no threshold.
|
|
|
|
Both `severityFor(value, 75, 90)` call sites are gone (the task's explicit work item):
|
|
the KV row takes `severity` from the rated ratio cell, the fleet KV bar was deleted
|
|
with the summed card, and every unrated number — throughput above all, because 0 tok/s
|
|
on an idle server is healthy — draws no colour at all.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
assert "severityFor(" not in js, "the LLM page re-invented a threshold"
|
|
assert "severityFrom" in js
|
|
# the KV verdict comes from the payload cells `sources/llm.py` rates
|
|
kv = js[js.index("case 'cache':"):js.index("case 'ttft':")]
|
|
assert "kv_cache_usage_ratio" in kv and "gpu_cache_usage" in kv and "severity" in kv
|
|
|
|
|
|
def test_llm_engine_panel_leads_with_two_throughput_heroes(client) -> None:
|
|
"""Decode and prompt are the largest elements per engine; the rest is visibly secondary.
|
|
|
|
The hero row is `Dash.hero()` markup (2.4rem via `.hero`), built from the `hero: true`
|
|
flags in `CELLS`, and throughput carries **no** severity — `severity.py` has no
|
|
throughput row and the page invents none (the neutral-ink decision in NOTES-chart-set,
|
|
mirroring the `/system` NET precedent). The provenance tag stays in the hero's `.sub`
|
|
with the exact `source` as the tooltip: removing it to look cleaner is a regression.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
panel = js[js.index("function enginePanel("):js.index("function renderPanel(")]
|
|
assert 'data-hero="${cell.key}"' in panel, "the hero row is not flag-driven markup"
|
|
assert '<div class="heroes">' in panel, "the hero row left the engine panel"
|
|
heroes = re.findall(r"{ key: '(\w+)'[^}]*?hero: true", js)
|
|
assert heroes == ["decode", "prompt"], "the hero row is no longer the two throughputs"
|
|
# driven by flags, not fourteen identical cells. Phase 07 task 03 lifted the summary rows
|
|
# out of the panel into the stats strip above it, so what is left here is the pair plus
|
|
# the latency rows the strip does not print — never a fourteen-cell grid again.
|
|
assert "SPARK_CELLS" in js and "ALARM_CELLS" in js
|
|
assert js.count("spark: '") >= 2, "the panel lost the latency sparkline rows it kept"
|
|
assert js.count("hero: true") == 2, "the hero row is no longer exactly two numbers"
|
|
# the hero call must not pass a severity — unrated by design
|
|
hero_call = js[js.index("const node = hero("):js.index("if (node) node.title")]
|
|
assert "severity" not in hero_call, "throughput invented a verdict"
|
|
# provenance intact: the tag is generated from the cell's `source`, never hardcoded
|
|
assert "provenance" in js and "Δ" in js
|
|
assert "node.title = value.source" in js
|
|
|
|
|
|
def test_llm_provenance_tag_prints_the_window_the_payload_sent(client: TestClient) -> None:
|
|
"""`Δ2.0s` on `/llm` is the cell's own `window`, never a config echo (phase 23).
|
|
|
|
The window moved from 10 s to one poll interval without touching the page: `provenance()`
|
|
reads `cell.window`, which is the number `tests/test_llm_source.py` pins to the LLM span, and
|
|
both spell the label with the same one-decimal rounding (`delta_label` in
|
|
`tests/rate_window_contract.py` is that rounding, used by both payload pins). A page printing
|
|
the configured interval instead would advertise a window no poll measured (rule 2), and a
|
|
literal `Δ10.0s` in the script is the same lie with the number typed in.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
prov = js[js.index("function provenance("):js.index("function humanNumber(")]
|
|
assert "`Δ${cell.window.toFixed(1)}s`" in prov, \
|
|
f"the Δ tag is no longer built from the window the cell carries: {prov}"
|
|
assert delta_label(LLM_WINDOW) == "Δ2.0s", "the rounding the page and the pins share moved"
|
|
assert "rate_window_seconds" not in js, "the page echoes a config window beside a measurement"
|
|
assert "Δ10.0s" not in js, "a hard-coded Δ is an invented window (rule 2)"
|
|
|
|
|
|
def test_every_llm_cell_carries_a_window_sparkline_with_percentile_feet(client) -> None:
|
|
"""Each panel row draws its own sparkline over the *selected* window and prints mean
|
|
**and** p95 without a hover.
|
|
|
|
The window is honest time (`align: 'right'` + `span`) and the p95 comes from the
|
|
snapshot's histogram cells — p95 needs no stored series. The `only` scopes are what keep
|
|
a gufo panel from showing a latency mystery and a vLLM strip card from showing a
|
|
spec-decode row it cannot fill.
|
|
|
|
Phase 07 task 03 moved the percent and count rows to the stats strip, so the plotted
|
|
percent series left the cell list and the axis guarantee moved with it: the KV trend
|
|
chart pins `yMax: 100` (an 8 % window that once held a 92 % spike must not read flat),
|
|
and the strip converts its 0-1 ratios to per cent in `cellValue()` — a bar cannot
|
|
autoscale, so it cannot lie that way either. The `fixedMax`/`minSpan` options stay
|
|
plumbed through `drawSparks()` and are pinned on the component itself by
|
|
`test_sparkline_ships_the_fixed_scale_and_verdict_options`: a count sparkline that comes
|
|
back must not get an axis finer than one request.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
sparks = js[js.index("function drawSparks("):js.index("function renderFleet(")]
|
|
assert "DashCharts.sparkline(" in sparks
|
|
for opt in ("align: 'right'", "span: state.range", "fixedMax", "labels: { max: true }"):
|
|
assert opt in sparks, f"cell sparklines lost `{opt}`"
|
|
# the one percent series still plotted owns a fixed 0-100 axis; seconds stay ms
|
|
charts = js[js.index("function makeCharts("):js.index("function pointsFor(")]
|
|
assert "yMax: 100" in charts, "the KV trend chart no longer pins its 0-100 axis"
|
|
assert "scale: 1000" in js and "cell.scale || 1" in sparks
|
|
# every spark cell declares its history series in the markup it is built from
|
|
assert 'data-spark data-metric="${cell.spark}"' in js
|
|
# percentile foot lines: mean big in the row, p95 beneath, n/a when nothing measured
|
|
assert "function p95Value(" in js
|
|
for name in ("time_to_first_token_seconds__p95", "time_per_output_token_seconds__p95",
|
|
"request_queue_time_seconds__p95"):
|
|
assert name in js, f"{name} no longer feeds a foot line"
|
|
assert "p95 n/a" in js
|
|
assert 'data-foot' in js
|
|
# engine-scoped rows: gufo never grows a TTFT row, radiance joins the vLLM latency
|
|
# scope (the vLLM-shaped histograms this build carries, task 03) and spec acceptance
|
|
# (the gauge reuses gufo's key, the same 0-1 scale)
|
|
assert "only: ['vllm', 'radiance']" in js and "only: ['gufo', 'radiance']" in js
|
|
assert "only: ['gufo']" in js, "slots busy left its gufo scope"
|
|
scoped = js[js.index("function sparkCellsFor("):js.index("function enginePanel(")]
|
|
assert "cell.only" in scoped
|
|
assert "minSpan: cell.minSpan" in sparks, "cell minSpan never reaches the sparkline"
|
|
|
|
|
|
def test_llm_sparklines_and_charts_cost_one_batched_history_request(client) -> None:
|
|
"""One `/api/llm/history` call per refresh feeds charts *and* every sparkline row.
|
|
|
|
The endpoint answers a metric list for every engine, and unknown names come back as
|
|
empty series on purpose — so the page asks for the full cell set once instead of one
|
|
XHR per cell, and the two series phase 04 added (`tpot_seconds`, `spec_accept_rate`)
|
|
must be in that list or those rows are permanent "no history" slots.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
# comments aside, exactly one fetch hits the endpoint: one request, every metric
|
|
calls = re.findall(r"fetchJSON\(\s*`/api/llm/history", _code(js))
|
|
assert len(calls) == 1, "the page makes more than one history request again"
|
|
assert "HISTORY_SERIES.join" in js and "SPARK_CELLS.map((cell) => cell.spark)" in js
|
|
request = js[js.index("async function refreshHistory("):js.index("function drawSparks(")]
|
|
assert "metrics=${HISTORY_SERIES.join" in request
|
|
assert "range=${state.range}" in request
|
|
# and the endpoint really answers every name the page asks for
|
|
for name in ("tpot_seconds", "spec_accept_rate", "ttft_seconds", "queue_seconds",
|
|
"kv_cache_usage", "requests_running", "requests_waiting",
|
|
"prefix_cache_hit_ratio"):
|
|
assert name in HISTORY_METRICS, f"llm.{name} is not stored, so its spark is dead"
|
|
|
|
|
|
def test_llm_alarm_cells_exist_only_while_there_is_an_incident(client) -> None:
|
|
"""`preemptions` / `device_lost` are absent at zero and red chips above zero.
|
|
|
|
The rule is the *existence of an incident*, not a graded threshold — `sources/llm.py`
|
|
already treats any non-zero `gufo_device_lost_total` as one. The chip reuses the shared
|
|
`.chip.fail` vocabulary; nothing new is painted, and a container emptied at zero means
|
|
the row does not exist rather than showing a permanent ignored 0.
|
|
|
|
Phase 07 task 03 renders them on the engine's strip card (the summary you read first)
|
|
instead of inside the panel, so the two `alarm: true` rows live in `STRIP_ROWS` and
|
|
`ALARM_CELLS` filters them out of it — counted with the comments stripped, because the
|
|
docstring on that table explains the flag by name.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
alarms = js[js.index("function renderAlarms("):js.index("function makeCharts(")]
|
|
assert "class=\"chip fail\"" in alarms, "the alarm chip left the shared fail vocabulary"
|
|
assert "value.value > 0" in alarms
|
|
assert "host.hidden = chips.length === 0" in alarms
|
|
assert 'data-alarm="${cell.key}"' in alarms
|
|
assert _code(js).count("alarm: true") == 2
|
|
assert "ALARM_CELLS = STRIP_ROWS.filter" in js, "the alarm rows are not strip rows"
|
|
# the panel no longer carries a second container for the same incident
|
|
panel = js[js.index("function enginePanel("):js.index("function renderPanel(")]
|
|
assert 'data-k="alarms"' not in panel, "the alarm chips are drawn twice"
|
|
strip = js[js.index("function stripCard("):js.index("function renderStripCard(")]
|
|
assert 'data-k="alarms"' in strip, "the alarm chips left the strip card"
|
|
|
|
|
|
# ---- the `/llm` stats strip (phase 07 task 03) ---------------------------
|
|
#
|
|
# `llm_monitor`'s shared strip (`index.html` ~L743), ported: one card per configured engine
|
|
# between the fleet block and the panels. Two things make it a summary instead of a second
|
|
# set of panels, and both are pinned below: every row is a cell `/api/llm/snapshot` really
|
|
# publishes, and no row is printed in the strip *and* in the panel under it.
|
|
|
|
#: row key -> the cells `cellValue()` resolves it from (`sessions.*` is the gufo slot count,
|
|
#: `stats.*` radiance's /stats subset — neither is a metrics cell). The served `STRIP_ROWS`
|
|
#: table must match these keys exactly.
|
|
STRIP_ROW_PATHS: dict[str, tuple[str, ...]] = {
|
|
"prompt": ("prompt_tokens_per_second",),
|
|
"decode": ("decode_tokens_per_second",),
|
|
"tokens_total": ("tokens_total",),
|
|
"slots_busy": ("sessions.list", "sessions.active"),
|
|
"running": ("requests_processing", "num_requests_running"),
|
|
"waiting": ("requests_deferred", "num_requests_waiting"),
|
|
"cache_hit": ("prefix_cache_hit_ratio", "prompt_cache_hit_ratio"),
|
|
"spec_accept": ("spec_accept_rate",),
|
|
"queue_mean": ("request_queue_time_seconds__mean",),
|
|
"cap": ("queue_cap",),
|
|
"build": ("stats.version",),
|
|
"uptime": ("stats.uptime_s",),
|
|
"vram": ("stats.cards",),
|
|
"preemptions": ("num_preemptions_total",),
|
|
"device_lost": ("device_lost_total",),
|
|
}
|
|
|
|
#: rows that are incidents, not stats: absent where the engine does not count them.
|
|
ALARM_ROW_KEYS = ("preemptions", "device_lost")
|
|
|
|
#: (row key, engine) pairs the scope keeps even though **this capture** does not publish
|
|
#: the cell: the series is standard for the engine family, so another build fills it.
|
|
#: `queue_mean` on radiance — this build publishes no `request_queue_time_seconds` (the
|
|
#: vLLM-compat family is pinned in RADIANCE_OPTIONAL), so the row prints `n/a` by
|
|
#: absence, never back-filled. A row the engine could *never* fill is not shown at all.
|
|
DOCUMENTED_ABSENCE = {("queue_mean", "radiance")}
|
|
|
|
|
|
def _strip_rows(js: str) -> dict[str, str | None]:
|
|
"""The served `STRIP_ROWS` table: row key -> its `only` scope (`None` = every engine)."""
|
|
body = _code(js)
|
|
start = body.index("const STRIP_ROWS = [")
|
|
table = body[start:body.index("\n];", start)]
|
|
rows: dict[str, str | None] = {}
|
|
for chunk in table.split("{ key: '")[1:]:
|
|
scoped = re.search(r"only: \[([^\]]*)\]", chunk)
|
|
rows[chunk.split("'")[0]] = scoped.group(1) if scoped else None
|
|
return rows
|
|
|
|
|
|
def _strip_render_path(js: str) -> str:
|
|
"""Just `renderStripCard()` — the whole code path that paints one strip card."""
|
|
js = _code(js)
|
|
return js[js.index("function renderStripCard("):js.index("function pruneEngines(")]
|
|
|
|
|
|
def test_llm_stats_strip_sits_between_the_fleet_block_and_the_panels(client) -> None:
|
|
"""The strip is there, ships hidden, names itself, and appears only with engines.
|
|
|
|
An unconfigured engine gets no card and no strip (invariant 3) — the section is empty in
|
|
the markup and `page-llm.js` unhides it only when the payload has an engine to draw.
|
|
"""
|
|
html = _code(client.get("/llm").text)
|
|
assert 'id="strip"' in html, "/llm lost the per-engine stats strip"
|
|
assert html.index('id="fleet"') < html.index('id="strip"') < html.index('id="engines"'), \
|
|
"the strip is no longer between the fleet block and the engine panels"
|
|
start = html.index('<section class="strip"')
|
|
tag = html[start:html.index(">", start)]
|
|
assert "hidden" in tag, "the strip no longer ships hidden"
|
|
assert 'aria-label="Engine summary"' in tag, "the strip is a region that names nothing"
|
|
js = client.get("/static/page-llm.js").text
|
|
assert "$('#strip').hidden = payloads.length === 0" in js
|
|
|
|
|
|
def test_llm_strip_card_is_one_card_per_engine_of_shared_vocabulary(client) -> None:
|
|
"""`stripCard()` = `.card` + `.kv` + `.bar` + `.chip.fail`: a layout, not a new component.
|
|
|
|
Dot, name and age in the head (reachability, the same distinction the panels and the
|
|
fleet block draw), the KV bar, the rows keyed by payload field, the alarm container.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
card = js[js.index("function stripCard("):js.index("function renderStripCard(")]
|
|
assert "card.className = 'card stat'" in card
|
|
assert "card.dataset.engine = engine" in card, "the card stopped naming its engine"
|
|
assert '<span class="dot idle"></span>' in card
|
|
assert '<span class="faint mono updated"></span>' in card, "the card lost its timestamp"
|
|
assert '<span class="bar" data-kv-bar><i></i></span>' in card, "the KV bar left `.bar`"
|
|
assert '<dl class="kv">' in card, "the strip rows left the shared `.kv` vocabulary"
|
|
assert 'data-row="${row.key}"' in card
|
|
assert 'data-k="alarms"' in card
|
|
# the wrapper is why the shared `.kv` grid has to move *inside* it: applied to the
|
|
# wrappers, `max-content 1fr` lays two rows side by side and the card reads as a
|
|
# column of orphaned labels (found in the browser, not in a test failure)
|
|
css = client.get("/static/dash.css").text
|
|
section = css[css.index("/* ---- engine stats strip"):css.index("/* ---- fleet totals")]
|
|
assert ".strip .kv { margin: 0; grid-template-columns: 1fr; }" in section
|
|
assert ".strip .kv > div {" in section
|
|
assert "grid-template-columns: max-content minmax(0, 1fr)" in section
|
|
refresh = js[js.index("async function refresh("):js.index("async function refreshEvents(")]
|
|
assert "state.strips.get(payload.engine)" in refresh, "the strip rebuilds its cards per poll"
|
|
assert "$('#strip').appendChild(strip)" in refresh
|
|
assert "renderStripCard(strip, payload, stateRow)" in refresh
|
|
prune = js[js.index("function pruneEngines("):js.index("function makeCharts(")]
|
|
assert "state.strips" in prune and "state.panels" in prune, \
|
|
"an engine that leaves the payload leaves a card behind"
|
|
|
|
|
|
def test_llm_strip_scopes_its_rows_to_the_engines_that_publish_them(client) -> None:
|
|
"""gufo gets slots; vLLM and radiance get running and queue time; the radiance
|
|
strip's four own cells (cap / build / uptime / VRAM) are radiance's alone."""
|
|
rows = _strip_rows(client.get("/static/page-llm.js").text)
|
|
assert rows["slots_busy"] == "'gufo'" and rows["spec_accept"] == "'gufo', 'radiance'"
|
|
assert rows["running"] == "'vllm', 'radiance'" and rows["queue_mean"] == "'vllm', 'radiance'"
|
|
for key in ("cap", "build", "uptime", "vram"):
|
|
assert rows[key] == "'radiance'", f"the {key} row left its radiance scope"
|
|
assert rows["waiting"] is None and rows["cache_hit"] is None and rows["tokens_total"] is None
|
|
js = client.get("/static/page-llm.js").text
|
|
scoped = js[js.index("function stripRowsFor("):js.index("function stripCard(")]
|
|
assert "row.only" in scoped and "row.only.indexOf(engine)" in scoped
|
|
|
|
|
|
def test_llm_strip_rows_name_cells_the_engines_publish(client, metrics_text) -> None:
|
|
"""The strip renders what the payload sends — no row whose cell no engine produces.
|
|
|
|
`STRIP_ROW_PATHS` mirrors the JS table; every metric cell is checked against the
|
|
capture-parsed metrics of each engine that would render that row, so a rename in
|
|
`sources/llm.py` fails here instead of printing a permanent `n/a` in the browser, and a
|
|
row scoped to one engine cannot leak into the other's card. `DOCUMENTED_ABSENCE` is the
|
|
one exemption: a series standard for the engine family that this build's capture does
|
|
not publish (the row prints `n/a` by absence, never back-filled — the scope stays for
|
|
the builds that do).
|
|
"""
|
|
rows = _strip_rows(client.get("/static/page-llm.js").text)
|
|
assert set(rows) == set(STRIP_ROW_PATHS), \
|
|
f"the served strip rows drifted: {set(rows) ^ set(STRIP_ROW_PATHS)}"
|
|
parsed = {"gufo": parse_gufo_metrics(metrics_text("gufo")),
|
|
"vllm": parse_vllm_metrics(metrics_text("vllm")),
|
|
"radiance": parse_radiance_metrics(metrics_text("radiance"))}
|
|
for key, only in rows.items():
|
|
paths = STRIP_ROW_PATHS[key]
|
|
if paths[0].startswith("sessions.") or paths[0].startswith("stats."):
|
|
continue # its own payload object; pinned on the JS below
|
|
shown = {engine: metrics for engine, metrics in parsed.items()
|
|
if (not only or engine in only)
|
|
and (key, engine) not in DOCUMENTED_ABSENCE}
|
|
present = [engine for engine in shown if any(path in shown[engine] for path in paths)]
|
|
if key in ALARM_ROW_KEYS:
|
|
# an alarm the engine does not count simply is not there: the chip renders only
|
|
# while non-zero, so absence *is* the zero state rather than a dead row
|
|
assert present, f"no engine publishes {'/'.join(paths)} for the {key} alarm"
|
|
continue
|
|
assert len(present) == len(shown), (
|
|
f"the {sorted(set(shown) - set(present))} card would print `{key}` as n/a forever "
|
|
f"({'/'.join(paths)} is not published)")
|
|
|
|
|
|
def test_llm_strip_slot_count_counts_published_slots_and_never_invents_zero(client) -> None:
|
|
"""`slots busy` is a count of `/slots` rows; when /slots did not answer it is `n/a`.
|
|
|
|
Counting rows the server published is a count of published facts. An empty list from a
|
|
server that never answered is not a measurement of an idle engine, so the row falls back
|
|
to the published `sessions.active` carrying the API's own `detail` string (rule 2).
|
|
"""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
slots = js[js.index("case 'slots_busy':"):js.index("case 'cache':")]
|
|
assert "sessions.source" in slots and "sessions.list" in slots
|
|
assert "is_processing" in slots, "the count ignores the authoritative busy verdict"
|
|
assert "sessions.detail" in slots, "the fallback lost the API's own provenance"
|
|
|
|
|
|
def test_llm_strip_kv_bar_is_coloured_by_the_payload_verdict_only(client) -> None:
|
|
"""The strip's KV bar is the page's one rated cell: `severity` in, class out.
|
|
|
|
`sources/llm.py` rates `llm.kv_cache_usage_pct`, the ratio→percent conversion the panel
|
|
cell used to make rides with it, and `Dash.severityFrom` is the only thing that decides a
|
|
colour — no threshold in the page, and the stylesheet adds no palette (`ok` stays quiet
|
|
ink, exactly like the `.cell` rows and the fleet table).
|
|
"""
|
|
body = _strip_render_path(client.get("/static/page-llm.js").text)
|
|
assert "severityFrom(kv.severity)" in body
|
|
assert "cellValue(engine, metrics, sessions, 'cache')" in body
|
|
assert "Math.max(0, Math.min(100, kv.value))" in body, "the fill stopped clamping"
|
|
assert body.count("severityFrom") == 1, "a second strip row invented a verdict"
|
|
css = client.get("/static/dash.css").text
|
|
section = css[css.index("/* ---- engine stats strip"):css.index("/* ---- fleet totals")]
|
|
assert ".stat-bar.warn .value { color: var(--warn); }" in section
|
|
assert ".stat-bar.fail .value { color: var(--fail); }" in section
|
|
assert ".stat-bar.ok" not in section, "a healthy KV reading must stay quiet ink"
|
|
assert ".prov:empty" in section, "an empty provenance tag draws a pill of nothing"
|
|
|
|
|
|
def test_llm_strip_throughput_carries_provenance_without_colour(client) -> None:
|
|
"""`prompt tok/s` and `decode tok/s` keep the `Δ<window>` tag and stay ink.
|
|
|
|
The tag is generated from the cell's `source` (never hardcoded), the exact source rides
|
|
in the row's tooltip, and `severity.py` rates no throughput — so nothing here may paint
|
|
it (0 tok/s on an idle engine is healthy).
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
body = _strip_render_path(js)
|
|
assert "row.rate ? provenance(value) : ''" in body
|
|
assert "notes.join" in body and "value.source" in body
|
|
assert "severityFor" not in js
|
|
|
|
|
|
def test_llm_strip_and_panel_print_no_value_twice(client) -> None:
|
|
"""One value, one home — the UPS hero row's rule, applied across two components.
|
|
|
|
Task 03 lifted sessions, queue depth, KV pressure, the cache-hit and spec-accept percents,
|
|
the lifetime token counter and the alarm chips out of the panel cell list rather than
|
|
drawing them twice. The two throughput heroes are the exemption the task names: the panel
|
|
keeps its hero row (so does `llm_monitor`, which carries the pair in both places), and
|
|
everything else appears once. Queue time moved with its p95 foot, so the move lost nothing.
|
|
"""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
start = js.index("const CELLS = [")
|
|
panel_keys = set(re.findall(r"\{ key: '(\w+)'", js[start:js.index("\n];", start)]))
|
|
assert panel_keys == {"decode", "prompt", "ttft", "tpot"}, \
|
|
f"the panel cell list changed shape: {sorted(panel_keys)}"
|
|
shared = set(_strip_rows(js)) & panel_keys
|
|
assert shared == {"decode", "prompt"}, f"printed in the strip and the panel: {sorted(shared)}"
|
|
body = _strip_render_path(js)
|
|
assert "p95Value(metrics, row.key)" in body, "queue time lost its p95 in the move"
|
|
|
|
|
|
def test_llm_strip_prints_no_peak_context_number(client) -> None:
|
|
"""The phase's assumption, pinned: `llm_monitor`'s peak-ctx row does not come over.
|
|
|
|
Its cell is `llamacpp:n_tokens_max`, a since-start maximum rather than a glance number,
|
|
and the task says this strip gets no peak-context cell. The series stays stored and
|
|
queryable from `/api/llm/history` — not plotting it is not removing it.
|
|
"""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
assert "n_tokens_max" not in js, "/llm reads the peak-context gauge again"
|
|
assert not [key for key in _strip_rows(js) if "peak" in key]
|
|
|
|
|
|
def test_llm_fleet_block_leads_the_page_with_the_engine_sums(client) -> None:
|
|
"""The page opens with llm_monitor's aggregate block: the sums no panel can say.
|
|
|
|
Phase 04 retired these numbers to counts; the operator asked for the aggregate
|
|
throughput back (`TODO.md:5`), and phase 07 restores it in `llm_monitor`'s shape —
|
|
status dot, "all engines", an `N of M engines` tag, the note that the sums cover only
|
|
the engines answering now, then running / waiting / prompt / decode / total tokens.
|
|
The counts-only card it replaces (`#totals`, `f-live`, `f-active`) is gone, ids and all:
|
|
an element nothing writes is the orphan this repo pins against.
|
|
"""
|
|
html = _code(client.get("/llm").text)
|
|
assert 'id="fleet"' in html, "/llm lost the aggregate block"
|
|
assert html.index('id="fleet"') < html.index('id="engines"'), \
|
|
"the fleet block is no longer above the engine panels"
|
|
for part in ("fleet-dot", "fleet-count", "f-running", "f-waiting", "f-prompt",
|
|
"f-decode", "f-tokens"):
|
|
assert f'id="{part}"' in html, f"/llm lost the fleet block's {part}"
|
|
for words in ("all engines", "sums only engines answering now"):
|
|
assert words in html, f"/llm lost the fleet block's `{words}` line"
|
|
for gone in ('id="totals"', "f-live", "f-total", "f-active", "f-kv", "seam-note"):
|
|
assert gone not in html, f"/llm still carries {gone} from the retired counts card"
|
|
|
|
js = client.get("/static/page-llm.js").text
|
|
body = _fleet_render_path(js)
|
|
assert "block.hidden = total < 2" in body, "the fleet block no longer hides for one engine"
|
|
assert "hidden" in _fleet_tag(html), \
|
|
"the block no longer ships hidden, so a single engine would show a fleet row"
|
|
|
|
|
|
def test_llm_fleet_block_reads_only_paths_the_api_publishes(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Every number the block prints is a `fleet` key of `/api/llm/snapshot`.
|
|
|
|
The page sums nothing itself — `fleet_totals()` does — so the field table in
|
|
`page-llm.js` is a list of API paths, and a rename in `sources/llm.py` must fail here
|
|
rather than print a permanent `n/a` in the browser. Pinned with both engines configured
|
|
(the keys exist, empty-handed because nothing answered) and with neither (404
|
|
`configured: false` without dialling, invariant 3 — and the shipped block stays hidden).
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
|
|
vllm_url=DEAD_URL) as test_client:
|
|
fleet = test_client.get("/api/llm/snapshot").json()["fleet"]
|
|
assert set(fleet) >= FLEET_BLOCK_PATHS, FLEET_BLOCK_PATHS - set(fleet)
|
|
# the served field table is exactly the sum rows, in llm_monitor's order
|
|
js = test_client.get("/static/page-llm.js").text
|
|
table = _fleet_field_metrics(js)
|
|
assert table == ["requests_active", "requests_waiting",
|
|
"prompt_tokens_per_second", "decode_tokens_per_second",
|
|
"tokens_total"], "the fleet row changed shape without the API"
|
|
assert set(FLEET_SUM_PATHS) == set(table), "the block totals something the API does not"
|
|
# and the tag's two numbers are API paths too, not arithmetic the page invented
|
|
body = _fleet_render_path(js)
|
|
assert "fleet.engines_total" in body and "fleet.engines_live" in body
|
|
|
|
with build_client(tmp_path, monkeypatch) as bare:
|
|
response = bare.get("/api/llm/snapshot")
|
|
assert response.status_code == 404
|
|
assert response.json()["configured"] is False
|
|
html = _code(bare.get("/llm").text)
|
|
assert 'id="fleet"' in html and "hidden" in _fleet_tag(html), \
|
|
"an unconfigured engine must not leave a fleet row on the page"
|
|
|
|
|
|
def test_llm_fleet_totals_drop_an_engine_that_stopped_answering(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A total presented as current is current: a dead engine contributes nothing.
|
|
|
|
`fleet_totals()`'s docstring is the contract and the block is where it is read. The
|
|
injected payload survives the dead-port polls (a failed poll keeps the last good one), so
|
|
these are the numbers a browser would get with gufo answering and vLLM down — the live
|
|
engine's sums verbatim, `engines_live` one of two, and not one zero where a number the
|
|
dead engine never published would sit (rule 2).
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
|
|
vllm_url=DEAD_URL) as test_client:
|
|
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
|
|
hub.jobs["llm:gufo"].state.payload = {
|
|
"engine": "gufo",
|
|
"metrics": {"decode_tokens_per_second": {"value": 41.0},
|
|
"prompt_tokens_per_second": {"value": 17.5},
|
|
"requests_processing": {"value": 2.0},
|
|
"requests_deferred": {"value": 1.0},
|
|
"tokens_total": {"value": 512.0}},
|
|
"sessions": {"active": 2, "list": []}}
|
|
fleet = test_client.get("/api/llm/snapshot").json()["fleet"]
|
|
assert fleet["engines_total"] == 2 and fleet["engines_live"] == 1
|
|
assert fleet["decode_tokens_per_second"] == pytest.approx(41.0)
|
|
assert fleet["prompt_tokens_per_second"] == pytest.approx(17.5)
|
|
assert fleet["requests_active"] == pytest.approx(2.0)
|
|
assert fleet["requests_waiting"] == pytest.approx(1.0)
|
|
assert fleet["tokens_total"] == pytest.approx(512.0)
|
|
|
|
# and with neither engine answering, every sum is null, never a confident 0
|
|
hub.jobs["llm:gufo"].state.payload = {"engine": "gufo", "metrics": {},
|
|
"sessions": {"active": None, "list": []}}
|
|
empty = test_client.get("/api/llm/snapshot").json()["fleet"]
|
|
assert empty["engines_live"] == 0
|
|
assert [empty[key] for key in FLEET_SUM_PATHS] == [None] * len(FLEET_SUM_PATHS)
|
|
|
|
|
|
def test_llm_fleet_block_proves_where_its_sums_come_from(client) -> None:
|
|
"""The seam tooltip, the excluded-engine tooltip, and an `n/a` with no unit.
|
|
|
|
Two provenance rules the rest of the app holds to, on the one block that adds engines
|
|
together: the prompt sum carries `fleet.seams` — the API's own words on why gufo's and
|
|
vLLM's prompt counters are not the same quantity — and the tag's tooltip names the
|
|
engines `fleet_totals()` left out, so a partial total says so in words and not only by
|
|
being dimmed. And a sum the API did not publish prints `n/a` with its unit cleared: "n/a
|
|
tok/s" would read as a measurement of nothing.
|
|
"""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
body = _fleet_render_path(js)
|
|
assert "fleet.seams" in body, "the prompt sum lost the API's seam note"
|
|
assert "not answering — excluded from every sum below" in body, \
|
|
"the tag no longer names the engines left out of the totals"
|
|
assert "value == null ? ''" in body, "an n/a sum can still print a unit"
|
|
|
|
assert "value == null ? ''" in body, "an n/a sum can still print a unit"
|
|
|
|
|
|
def test_llm_fleet_block_colours_no_number(client) -> None:
|
|
"""No verdict on the aggregate row — and no dimming of anything but the tag.
|
|
|
|
`severity.py` rates no throughput and no queue depth (0 tok/s on an idle fleet is
|
|
healthy), so the block renders ink: the render path touches no `severity`, calls no
|
|
`Dash.hero()`, and the stylesheet section for the block reaches for no verdict token.
|
|
The one coloured thing on it is the shared **reachability** dot — the same distinction
|
|
the engine panels draw between their numbers and their header dot — and the partial-fleet
|
|
signal is the shared `.stale` dim on the count tag, which is opacity, not a verdict.
|
|
"""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
body = _fleet_render_path(js)
|
|
assert "severity" not in body, "the fleet block invented a verdict"
|
|
assert "hero(" not in body, "the fleet block drew a hero number it cannot rate"
|
|
assert "setDot(" in body and "markStale(tag" in body, \
|
|
"a dead engine no longer dims the tag or darkens the block's dot"
|
|
|
|
css = client.get("/static/dash.css").text
|
|
section = _css_section(css, "fleet totals")
|
|
for token in ("var(--ok)", "var(--warn)", "var(--fail)"):
|
|
assert token not in section, f"the fleet block coloured a number with {token}"
|
|
assert ".fleet-head" in section and ".fleet-note" in section, \
|
|
"the fleet block's head row lost its styling"
|
|
|
|
|
|
#: Elements the browser treats as empty, so they never open a nesting level.
|
|
_VOID_TAGS = {"meta", "link", "br", "img", "input", "hr", "source", "track", "wbr"}
|
|
|
|
|
|
def _depth_at(html: str, needle: str) -> int:
|
|
"""How deeply the `needle` sits inside `<main>` (1 = a direct child of it).
|
|
|
|
The `~` in `#banners:empty ~ #fleet` only resolves between **siblings**, so the gap rule
|
|
depends on the markup as much as on the stylesheet. Counting tags is enough here and keeps
|
|
the check on the served bytes: no HTML parser, no dependency, and comments are already
|
|
gone by the time `_code` hands the page over.
|
|
"""
|
|
body = html[html.index("<main>"):html.index(needle, html.index("<main>"))]
|
|
depth = 0
|
|
for closing, tag, _attrs, self_closed in re.findall(
|
|
r"<(/?)([a-zA-Z][\w-]*)([^>]*?)(/?)>", body):
|
|
if self_closed or tag.lower() in _VOID_TAGS:
|
|
continue
|
|
depth += -1 if closing else 1
|
|
return depth
|
|
|
|
|
|
def test_llm_section_stack_keeps_the_shared_vertical_rhythm(client) -> None:
|
|
"""Fleet block → engine summaries → engine panels sit the shared `.85rem` apart.
|
|
|
|
`TODO.md:4`: the fleet section had no spacing between it and the engine summaries. Every
|
|
boundary below it was already handled inline — the section under `#strip` carries
|
|
`margin-top:.85rem` — so `#fleet` and `#strip` skipped the rhythm and the top of the page
|
|
read as one unbroken wall. Measured in Chromium at the 16px root (`data/logs/`
|
|
`llm-fleet-spacing*.png`), before → after:
|
|
|
|
| gap | before | after |
|
|
|-----|--------|-------|
|
|
| `#fleet` → `#strip` | **0 (flush)** | 13.59px |
|
|
| `#strip` → engine panels | 13.59px | 13.59px |
|
|
| banner slot → `#fleet`, no banner | 13.59px | 13.59px |
|
|
| banner slot → `#fleet`, one banner up | 13.59px | 13.59px (`margin-top: 0px`) |
|
|
|
|
One rendered distance moves, and it is the one the operator named. What the *shape* of the
|
|
two rules buys is that no other state can put it back:
|
|
|
|
* `#strip`'s gap is **unconditional**, because below two engines `#fleet` is `[hidden]`
|
|
(`display:none !important`, so it contributes nothing) and the strip is then the first
|
|
visible section: the 13.59px below the banner slot in that state is now the strip's own
|
|
margin rather than one borrowed from a slot above it, which is what `#fleet ~ #strip`
|
|
would have been;
|
|
* `#fleet`'s gap is guarded by `#banners:empty`, so the block owns its top gap exactly when
|
|
nothing occupies the slot above it, and a banner's own `margin-bottom:.85rem` stays the
|
|
single gap under it. An unguarded `#fleet { margin-top }` is a second rule for one gap:
|
|
margin collapsing hides it at `.85rem`/`.85rem` today, and it doubles the moment either
|
|
margin is changed. The empty slot keeps its inline bottom margin, so head → fleet stays
|
|
the 1rem + .85rem the other two banner pages (`/`, `/ups`) already render.
|
|
|
|
Pinned on the served bytes because a stylesheet "cleanup" that drops either rule is
|
|
invisible in a diff and only shows up as a page that reads worse.
|
|
"""
|
|
raw = _css(client)
|
|
section = _css_section(raw, "fleet totals")
|
|
assert "#strip { margin-top: .85rem; }" in section, \
|
|
"the fleet block and the engine summaries render flush again (TODO.md:4)"
|
|
assert "#banners:empty ~ #fleet { margin-top: .85rem; }" in section, (
|
|
"the gap above the fleet block stopped belonging to the block: a banner-less page "
|
|
"would then have it only as long as the empty banner slot keeps its inline margin")
|
|
|
|
# one rule per element: a second one (or an unguarded `#fleet`) is the double gap
|
|
css = _code(raw)
|
|
for element, expected in (("#strip", {"#strip"}),
|
|
("#fleet", {"#banners:empty ~ #fleet"})):
|
|
selectors = {s.strip() for s in re.findall(rf"([^{{}}]*{element}[^{{}}]*)\{{", css)}
|
|
assert selectors == expected, (
|
|
f"dash.css styles {element} with {sorted(selectors)} — exactly one rule, the "
|
|
"guarded one, or the page grows a double gap or loses the single one")
|
|
|
|
html = _code(client.get("/llm").text)
|
|
for element in ("banners", "fleet", "strip"):
|
|
assert _depth_at(html, f'id="{element}"') == 1, (
|
|
f"`#{element}` is no longer a direct child of <main>, so the `~` gap rule cannot "
|
|
"match: rewrite the selectors for the new structure instead of dropping the gap")
|
|
assert html.index('id="banners"') < html.index('id="fleet"') < html.index('id="strip"') \
|
|
< html.index('id="engines"'), "the section stack was re-ordered"
|
|
|
|
# the inline half of the rhythm, which the two rules are written around
|
|
assert 'style="margin-bottom:.85rem"' in html[:html.index('id="fleet"')], (
|
|
"#banners lost the inline bottom margin that is the banner → fleet gap, and today the "
|
|
"only thing spacing the page head from the block")
|
|
after_strip = html[html.index('id="strip"'):]
|
|
assert after_strip.count('style="margin-top:.85rem"') == 3, \
|
|
"a section below the strip lost the inline gap the strip's rule hands off to"
|
|
|
|
|
|
# ---- the `/llm` gufo slot grid (phase 07 task 04) -------------------------
|
|
#
|
|
# `llm_monitor`'s slot cards (`index.html` ~L1179), rebuilt against the capture instead of a
|
|
# memory: one keyed card per gufo slot, every cell naming **one** key of
|
|
# `tests/fixtures/slots_gufo.json`. Two things are pinned below, because they are the two ways
|
|
# this component failed before: a card that reads a key the server does not publish (dashes
|
|
# forever), and a card that is rebuilt every poll so a busy slot flickers in front of you.
|
|
|
|
#: The keys a slot card reads, and where they live: `field` (and its optional `sub` /
|
|
#: `ratioAgainst` partners) index the row, or its first `next_token` entry when `path` says so.
|
|
SLOT_ROW_PATHS = ("field", "sub", "ratioAgainst")
|
|
|
|
|
|
def _slot_table(js: str) -> list[dict[str, str | None]]:
|
|
"""The served `SLOT_FIELDS` table, one dict per printed cell."""
|
|
body = _code(js)
|
|
start = body.index("const SLOT_FIELDS = [")
|
|
table = body[start:body.index("\n];", start)]
|
|
cells: list[dict[str, str | None]] = []
|
|
for chunk in table.split("{ key: '")[1:]:
|
|
cell: dict[str, str | None] = {"key": chunk.split("'")[0]}
|
|
for option in (*SLOT_ROW_PATHS, "path"):
|
|
found = re.search(rf"{option}: '([a-z_0-9]+)'", chunk)
|
|
cell[option] = found.group(1) if found else None
|
|
cells.append(cell)
|
|
return cells
|
|
|
|
|
|
def _slot_js(js: str) -> str:
|
|
"""The slot grid's whole code path, comments out: `slotValue()` to the end of the block."""
|
|
body = _code(js)
|
|
return body[body.index("function slotValue("):body.index("function makeCharts(")]
|
|
|
|
|
|
def _slot_markup(js: str) -> str:
|
|
"""Just `slotSection()` — the markup the grid, its label and its empty states are built from."""
|
|
body = _code(js)
|
|
return body[body.index("function slotSection("):body.index("function renderPanel(")]
|
|
|
|
|
|
def test_llm_gufo_panel_holds_the_slot_grid_under_its_label(client) -> None:
|
|
"""`sessions (GET /slots)`, then the keyed grid, then the note about what it cannot contain.
|
|
|
|
The label is `llm_monitor`'s, the grid ships `hidden` (the empty state is a sentence, never
|
|
a card of nothing), and the note stays because a queued (deferred) request is not in
|
|
`/slots` at all — without that line an idle grid reads as "nothing waiting".
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
markup = _slot_markup(js)
|
|
assert "sessions (GET /slots)" in markup, "/llm lost the slot grid's label"
|
|
# label, grid, empty state, note — in that order, and the grid and the empty state both
|
|
# ship hidden so nothing paints before the payload says which of them is true
|
|
order = ['class="section-label" data-slots-label', "sessions (GET /slots)",
|
|
'<div class="slots" data-slots role="list" hidden>',
|
|
'class="slots-empty" data-slots-empty hidden',
|
|
'class="slots-note" data-slots-note hidden']
|
|
position = -1
|
|
for needle in order:
|
|
found = markup.index(needle)
|
|
assert found > position, f"`{needle}` is out of order in the slot section"
|
|
position = found
|
|
assert "queued (deferred) requests are not in /slots" in js, "the queued-request note is gone"
|
|
assert "renderSlotGrid(card, enginePayload)" in js, "nothing draws the grid any more"
|
|
# the label is a heading and the grid a list: a screen reader gets the block's name and
|
|
# its item count, which a stack of divs does not say
|
|
assert '<h4 class="section-label" data-slots-label>' in markup, \
|
|
"the slot section's label stopped being a heading"
|
|
assert 'data-slots role="list"' in markup, "the grid lost its list semantics"
|
|
# the grid's own markup: a keyed card, the state pill, and one dd per printed cell
|
|
card = js[js.index("function slotCard("):js.index("function slotSub(")]
|
|
assert "setAttribute('role', 'listitem')" in card, "the slot cards left the list"
|
|
assert 'data-slot-cell="${cell.key}"' in card, "the card cells are not table-driven"
|
|
assert 'data-pill' in card and 'data-mark' in card, "the card lost its state pill or glyph"
|
|
|
|
|
|
def test_llm_slot_grid_style_is_a_marker_and_not_a_verdict(client) -> None:
|
|
"""A busy slot is a border, a glyph and a word — `severity.py` rates no slot.
|
|
|
|
Phase 06 settled this argument for GPU utilisation on an LLM server: a slot generating
|
|
tokens is the job, so the accent colour says *this is the live one* and no `--ok/--warn/
|
|
--fail` token appears anywhere in the section. The glyph (`○`/`●`) and the pill's word mean
|
|
the marker survives monochrome, which is the battery glyph's rule applied here.
|
|
"""
|
|
css = client.get("/static/dash.css").text
|
|
section = _css_section(css, "engine slot grid")
|
|
for selector in (".slots {", ".slot {", ".slot.busy", ".slot-top", ".slot-id",
|
|
".slot-model", ".slot dt", ".slot dd", ".slots-empty", ".slots-note",
|
|
".section-label"):
|
|
assert selector in section, f"dash.css lost the {selector} rule"
|
|
assert "var(--accent)" in section, "the busy marker stopped using the non-verdict accent"
|
|
for token in ("var(--ok)", "var(--warn)", "var(--fail)"):
|
|
assert token not in section, f"the slot grid coloured a state with {token}"
|
|
js = client.get("/static/page-llm.js").text
|
|
assert "'\\u25cf'" in js or "'●'" in js, "the busy glyph left the card"
|
|
assert "processing" in _slot_js(js), "the pill stopped saying the state in a word"
|
|
|
|
|
|
def test_llm_slot_cells_each_name_one_key_the_capture_publishes(client) -> None:
|
|
"""Every printed cell traces to a key of `tests/fixtures/slots_gufo.json`.
|
|
|
|
One `field` per cell and no `a ?? b`: the deleted table's `slot.n_prompt ??
|
|
slot.prompt_tokens` guessing is what made it print dashes and then plausible noise. A key
|
|
the capture does not have fails here, and the alias the capture answers beside `id_task`
|
|
must not appear in the page at all — the contract names the canonical one.
|
|
"""
|
|
# comments out: this test bans names the prose is allowed to explain
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
cells = _slot_table(js)
|
|
assert cells, "the slot grid has no cell table to read"
|
|
assert [cell["key"] for cell in cells] == ["req", "prompt", "cache", "generated",
|
|
"remain", "ctx"], \
|
|
"the slot card's cell set changed without the capture"
|
|
for cell in cells:
|
|
assert cell["field"], f"the {cell['key']} cell names no row key"
|
|
|
|
rows = load_slots_capture()
|
|
assert rows, "the capture is empty, so this test would prove nothing"
|
|
for cell in cells:
|
|
for option in SLOT_ROW_PATHS:
|
|
key = cell[option]
|
|
if key is None:
|
|
continue
|
|
in_next = cell["path"] == "next_token"
|
|
required = REQUIRED_NEXT_TOKEN_FIELDS if in_next else REQUIRED_ROW_FIELDS
|
|
optional = OPTIONAL_NEXT_TOKEN_FIELDS if in_next else OPTIONAL_ROW_FIELDS
|
|
assert key in required or key in optional, (
|
|
f"the {cell['key']} cell reads {key!r}, which no contract table declares — the "
|
|
"page would be reading a field nobody pinned")
|
|
for row in rows:
|
|
source = row["next_token"][0] if in_next else row
|
|
if key in required:
|
|
assert key in source, (
|
|
f"the {cell['key']} cell reads {key!r}, which "
|
|
f"{'next_token[0]' if in_next else 'the row'} does not publish — this is "
|
|
"how the deleted table rendered dashes")
|
|
elif key in source: # optional: `prob` is on no captured row, and that is legal
|
|
assert isinstance(source[key], (int, float)), (
|
|
f"{key!r} is {source[key]!r}, which the cell cannot format")
|
|
|
|
assert cells[0]["field"] == CANONICAL_TASK_ID_FIELD, \
|
|
"the request cell stopped reading the canonical task id"
|
|
for alias in TASK_ID_ALIASES:
|
|
assert alias not in js, f"page-llm.js renders the {alias} alias the contract does not name"
|
|
# the old table's invented names stay invented, and so does its per-slot speed
|
|
for gone in ("n_predict", "decode_per_second", "cache_usable", "n_ctx_used"):
|
|
assert gone not in js, f"page-llm.js reads the invented {gone} again"
|
|
|
|
|
|
def test_llm_slot_card_prints_no_speed_it_cannot_measure(client) -> None:
|
|
"""No per-slot speed cell: the capture publishes no per-slot rate key.
|
|
|
|
`llm_monitor`'s grid has one, and so did the deleted table (`decode_per_second`) — which is
|
|
why it was dashes. A page-side Δn_decoded/Δt would be a measurement with no estimator, no
|
|
`Δ<window>` label and no `source` string, i.e. rule 2 with a confident number printed on it,
|
|
so the number stays on the engine's strip card and panel, where `rates.py` owns it.
|
|
"""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
assert not [cell for cell in _slot_table(js)
|
|
if cell["key"] in {"speed", "tok_s", "decode"}], "a slot cell grew a rate"
|
|
assert "decode_per_second" not in js
|
|
|
|
|
|
def test_llm_slot_sentinels_print_a_word_or_na_never_a_negative_number(client) -> None:
|
|
"""`-1` is llama.cpp's "nothing in this slot", and it never becomes a countdown.
|
|
|
|
An idle slot answers `id_task: -1` and `next_token[0].n_remain: -1` (both in the capture).
|
|
The request cell says `idle` in a word; the remaining cell prints `n/a`. Neither prints a
|
|
negative number, and neither prints the `0` that would read as "one token from done".
|
|
"""
|
|
body = _slot_js(client.get("/static/page-llm.js").text)
|
|
assert "value < 0 ? 'idle'" in body, "the request cell prints the -1 sentinel again"
|
|
assert "value >= 0 ? value : null" in body, "the remaining cell prints the -1 sentinel"
|
|
|
|
|
|
def test_llm_slot_card_header_reads_only_fields_the_contract_declares(client) -> None:
|
|
"""The card top prints `model`, `speculative`, `id` and the state pair — and nothing else.
|
|
|
|
`id` keys the card (escaped once at build time), `is_processing` is the authoritative pill,
|
|
`state` labels a number the boolean cannot express, and `model` + `speculative` are the
|
|
footnote beside them. Each is declared in a contract table, so a rename fails there. The
|
|
user-content field a busy row carries (`prompt`) is the one key this page must never read.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
header = js[js.index("function renderSlotCard("):js.index("function renderSlotGrid(")]
|
|
read = {"is_processing", "state", "model", "speculative"}
|
|
for key in read:
|
|
assert f"row.{key}" in header, f"the slot card header stopped reading {key}"
|
|
declared = set(REQUIRED_ROW_FIELDS) | set(OPTIONAL_ROW_FIELDS)
|
|
assert read <= declared, "the header reads a field no contract table declares"
|
|
for field in USER_CONTENT_FIELDS:
|
|
assert field not in header, f"the slot card prints the user's {field}"
|
|
assert "escapeHtml(id)" in js, "the slot id reached the markup unescaped"
|
|
|
|
|
|
def test_llm_slot_cards_are_keyed_created_once_and_patched_in_place(client) -> None:
|
|
"""A busy slot does not get rebuilt every poll — and a slot that leaves loses its card.
|
|
|
|
`appendChild` of the existing node keeps DOM order == slot order; everything else is a text
|
|
patch, so a poll cannot flicker a card or re-announce it to assistive tech for a difference
|
|
of a few tokens. An engine that leaves the payload loses its card map with its panel, or the
|
|
refs of removed nodes would be re-appended when it came back.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
body = _slot_js(js)
|
|
assert "state.slots.get(enginePayload.engine)" in body, "the grid stopped keying its cards"
|
|
assert "if (!refs) {" in body and "refs = slotCard(id)" in body
|
|
assert "grid.appendChild(refs.root)" in body, "slot order is no longer kept by moving nodes"
|
|
assert "byId.delete(id)" in body, "a slot that left keeps its card forever"
|
|
assert "byId.forEach((refs) => refs.root.remove())" in body, \
|
|
"the empty state left cards on the page"
|
|
assert "setTxt(box.value" in body, "the cell values stopped being patched in place"
|
|
assert "innerHTML" not in body.split("function renderSlotCard(")[1], \
|
|
"a card is rebuilt from markup on a poll"
|
|
prune = js[js.index("function pruneEngines("):js.index("/* ---- the gufo slot grid")]
|
|
assert "state.slots.delete(engine)" in prune, "a pruned engine kept its slot cards"
|
|
|
|
|
|
def test_llm_slot_grid_says_why_there_are_no_rows(client) -> None:
|
|
"""Two honest empty states, and neither of them is a card or a 0-count claim.
|
|
|
|
`/slots` did not answer → the API's own `sessions.detail` sentence (the same words the
|
|
strip's session row carries). It answered with no rows → "engine reports 0 slots", which is
|
|
a fact about that answer. `sessions.source` is the gate that tells the two apart.
|
|
"""
|
|
body = _slot_js(client.get("/static/page-llm.js").text)
|
|
assert "Boolean(sessions.source)" in body, "the grid cannot tell no-answer from no-rows"
|
|
assert "engine reports 0 slots" in body
|
|
assert "sessions.detail ||" in body, "the no-answer state lost the API's own sentence"
|
|
assert "no /slots answer from this engine" in body, "the no-answer state can print undefined"
|
|
|
|
|
|
def test_llm_vllm_panel_says_there_is_no_per_slot_json(client) -> None:
|
|
"""vLLM gets one line and no grid — no fake rows for a server that cannot answer.
|
|
|
|
`sources/llm.py`'s `_sessions()` publishes counts for vLLM and says so in `detail`; the
|
|
panel turns that into the `llm_monitor` line, with the API's sentence as the tooltip. The
|
|
gate is the engine's name (`SLOT_ENGINES`), not `sessions.list.length`, so an engine that
|
|
has not answered yet still shows its label and the honest empty sentence. And the line
|
|
stays vLLM's: radiance's panel slot section is empty (its per-request rows live in the
|
|
strip), so a sentence naming vLLM on the radiance card is a line the engine never said.
|
|
"""
|
|
js = client.get("/static/page-llm.js").text
|
|
markup = _slot_markup(js)
|
|
branch = markup.split("if (engine === 'vllm') {")[1].split("}")[0]
|
|
assert 'class="slots-note" data-slots-note' in branch, "the vLLM line lost its element"
|
|
assert "NO_SLOT_JSON" in branch and "vLLM exposes no per-slot JSON" in js
|
|
assert '<div class="slots"' not in branch, "the vLLM panel grew a grid it cannot fill"
|
|
radiance_branch = markup.split("if (SLOT_ENGINES.indexOf(engine) < 0) {")[1].split("}")[0]
|
|
assert "return ''" in radiance_branch, "the radiance panel re-grew a slot section"
|
|
assert "NO_SLOT_JSON" not in radiance_branch, "the vLLM sentence leaked onto the radiance panel"
|
|
body = _slot_js(js)
|
|
assert body.startswith("function slotValue("), "the grid's code path moved"
|
|
assert "if (!grid) {" in body, "renderSlotGrid no longer leaves a panel with no grid alone"
|
|
assert "note.title = enginePayload.sessions.detail" in body, \
|
|
"the vLLM line stopped carrying the API's own words"
|
|
|
|
|
|
def test_the_captured_slot_rows_fill_the_cells_the_grid_prints(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The capture, through the API, into the keys the served cell table reads.
|
|
|
|
The fixture rows are injected as a live job payload (the `/slots` passthrough verbatim), so
|
|
this is the render path's input as the browser would receive it: `sessions.source` set (the
|
|
grid's gate), every row present, and every key `SLOT_FIELDS` names resolvable in it. A
|
|
rename in `sources/llm.py` or in gufo fails here rather than leaving a card of `n/a`s.
|
|
"""
|
|
rows = load_slots_capture()
|
|
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
|
|
vllm_url=DEAD_URL) as test_client:
|
|
hub = test_client.app.state.ctx.hub # type: ignore[attr-defined]
|
|
hub.jobs["llm:gufo"].state.payload = {
|
|
"engine": "gufo", "metrics": {"requests_processing": {"value": 1.0}},
|
|
"sessions": {"active": 1, "list": rows, "source": "gufo /slots", "detail": None},
|
|
}
|
|
engines = test_client.get("/api/llm/snapshot").json()["engines"]
|
|
gufo = next(row for row in engines if row["engine"] == "gufo")
|
|
sessions = gufo["sessions"]
|
|
assert sessions["source"] == "gufo /slots", "the grid's gate is gone from the payload"
|
|
assert sessions["list"] == rows, "the rows are no longer the passthrough the grid reads"
|
|
|
|
cells = _slot_table(test_client.get("/static/page-llm.js").text)
|
|
for cell in cells:
|
|
for option in SLOT_ROW_PATHS:
|
|
key = cell[option]
|
|
if key is None:
|
|
continue
|
|
in_next = cell["path"] == "next_token"
|
|
required = REQUIRED_NEXT_TOKEN_FIELDS if in_next else REQUIRED_ROW_FIELDS
|
|
for row in sessions["list"]:
|
|
source = row["next_token"][0] if in_next else row
|
|
if key not in required and key not in source:
|
|
continue # an optional sub-line this build does not publish
|
|
value = source[key]
|
|
assert isinstance(value, (int, float)) and not isinstance(value, bool), (
|
|
f"the {cell['key']} cell reads {key!r} = {value!r}, which the number "
|
|
"formatter cannot print")
|
|
|
|
|
|
def test_llm_charts_are_secondary_and_named(client) -> None:
|
|
"""The four trend charts stay, at half height, *below* the engine panels, each named.
|
|
|
|
Half of the old 165px and moved after `#engines` is the "cells read first" decision;
|
|
every `LineChart` gets an `ariaLabel` because the legend is a sibling that labels
|
|
nothing (`/system` did this in phase 03, `/llm` never had).
|
|
"""
|
|
html = _code(client.get("/llm").text)
|
|
assert html.index('id="engines"') < html.index('id="chart-decode"'), \
|
|
"the charts are back on top of the engine panels"
|
|
for chart in ("chart-decode", "chart-prompt", "chart-kv", "chart-queue"):
|
|
assert f'id="{chart}"' in html, f"/llm lost the {chart} chart"
|
|
js = client.get("/static/page-llm.js").text
|
|
assert "height: 110" in js and "height: 165" not in js
|
|
charts = js[js.index("function makeCharts("):js.index("function pointsFor(")]
|
|
assert charts.count("ariaLabel:") == 4, "an /llm chart lost its accessible name"
|
|
# the radiance strip's 7-bar spec curve (phase 34) joins the named list: `role="img"`
|
|
# and an `aria-label` computed from the payload's `spec_positions` — a label that
|
|
# cannot say which ratios it plots is not an accessible name, and an absent label
|
|
# fails here
|
|
curve = _code(client.get("/static/page-llm.js").text)
|
|
curve = curve[curve.index("function renderSpecCurve("):curve.index("function reqCellText(")]
|
|
assert 'role="img"' in curve, "the spec curve is an unlabelled plot"
|
|
assert 'aria-label="' in curve and "escapeHtml(label)" in curve, \
|
|
"the spec curve's label is not the computed one"
|
|
|
|
|
|
def test_the_radiance_spec_curve_is_neutral_and_built_from_the_payload(client) -> None:
|
|
"""Decision 4, pinned both ways: the 7-bar curve draws in the page's neutral ink,
|
|
reaches for no verdict token, and its label is computed from the payload — the
|
|
connector word turns on the first/last published ratio's order, and an absent
|
|
position stays absent rather than becoming a zero bar (rule 2)."""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
curve = js[js.index("function renderSpecCurve("):js.index("function reqCellText(")]
|
|
assert "spec_positions" in curve, "the curve stopped reading the payload's positions"
|
|
assert "cssVar('--text-faint')" in curve, "the curve left the neutral ink"
|
|
assert "Math.min(1, row.ratio)" in curve, "a ratio above 1 would stretch a bar"
|
|
for token in ("--ok", "--warn", "--fail", "severity"):
|
|
assert token not in curve, f"the spec curve grew a verdict: {token}"
|
|
assert "falling to" in curve and "rising to" in curve and "holding at" in curve, \
|
|
"the connector word is not computed from the payload's order"
|
|
assert "Number.isFinite(row.ratio)" in curve, \
|
|
"a missing position must stay absent, not zeroed"
|
|
|
|
|
|
def test_radiance_strip_cells_print_na_never_zero_when_stats_never_answered(client) -> None:
|
|
"""A radiance payload without the /stats block: cap / build / uptime / vram print
|
|
`n/a`, never `0` — the null guards on the served path (a missing sidecar is an
|
|
absent number, rule 2), and the number formatter never sees a string value."""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
value = js[js.index("function cellValue("):js.index("function p95Value(")]
|
|
assert "case 'cap':" in value and "get('queue_cap')" in value, \
|
|
"cap stopped reading the /stats passthrough the server degrades to null"
|
|
assert "stats && typeof stats.version === 'string'" in value, \
|
|
"build must be the engine's own string or null"
|
|
assert "Number.isFinite(stats.uptime_s)" in value and "duration(stats.uptime_s)" in value
|
|
assert "cards.find((c) => c && c.index === 0)" in value, "vram reads the wrong card"
|
|
assert "(used / GIB).toFixed(1)" in value and "2 ** 30" in js, \
|
|
"the one conversion is GiB = 2³⁰, labelled in the tip"
|
|
body = _strip_render_path(js)
|
|
assert "cellValue(engine, metrics, sessions, row.key, stats)" in body, \
|
|
"the radiance cells lost the payload's stats block"
|
|
assert "typeof value.value === 'string' ? value.value" in body, \
|
|
"a string value hits the number formatter"
|
|
|
|
|
|
def test_radiance_request_rows_render_as_is_and_the_rest_state_says_so(client) -> None:
|
|
"""The rows are the engine's own fields, as-is: the columns are the union of the
|
|
keys the rows publish (no invented key), a number prints only when it is a number,
|
|
anything else its string verbatim — and an empty list hides the table and puts the
|
|
API's own count-otherwise sentence in the note slot."""
|
|
js = _code(client.get("/static/page-llm.js").text)
|
|
card = js[js.index("function stripCard("):js.index("function renderStripCard(")]
|
|
assert 'data-spec-curve' in card and 'data-reqs' in card and 'data-reqs-note' in card, \
|
|
"the radiance card lost its curve / rows / note homes"
|
|
cell = js[js.index("function reqCellText("):js.index("function renderReqs(")]
|
|
assert "typeof value === 'number' && isFinite(value)" in cell, \
|
|
"a number prints only when it is a number"
|
|
assert "typeof value === 'string') return value" in cell, \
|
|
"anything else prints its string verbatim"
|
|
reqs = js[js.index("function renderReqs("):js.index("function makeCharts(")]
|
|
assert "sessions.list" in reqs and "Object.keys(row)" in reqs, \
|
|
"the columns are a hardcoded list again"
|
|
assert "indexOf(key) < 0" in reqs, "first-seen order must keep the rows as published"
|
|
assert "note.hidden = !sessions.detail" in reqs, \
|
|
"the rest state lost the API's own sentence"
|
|
assert "note.hidden = true" in reqs, "rows visible: the sentence must leave"
|
|
assert "table.title = sessions.source" in reqs, "the table's source left its tooltip"
|
|
# the gufo gate is untouched: the slot grid is gufo's, the radiance rows live in the strip
|
|
assert "SLOT_ENGINES = ['gufo']" in js
|
|
|
|
|
|
def test_llm_engine_windows_stretch_to_a_common_row_height(client) -> None:
|
|
"""The strip row and the panel row each end on a flat edge.
|
|
|
|
The `llm_monitor`-era pin forbade this on purpose: the shorter engine card's 172 px
|
|
blank bottom read as "no data" (phase 04). The operator judged the dead gap
|
|
*below* the shorter card, before the next row, the worse misread, and asked for the
|
|
window heights equalized (2026-10-25). The grid default `stretch` now takes over:
|
|
both cards of a row share the tallest card's height, and the shorter card's extra
|
|
space reads as bottom card padding — never a `0` or an invented number (rule 2: a
|
|
missing cell still prints `n/a`). Pinned on the served bytes so the decision cannot
|
|
silently revert in either direction: `#engines` carries no alignment of its own, the
|
|
`.strip` rule sets none while staying alive (its `auto-fit` columns and gap intact —
|
|
a dead rule is not a de-aligned one), and `/system`'s hosts grid keeps
|
|
`grid auto items-start` in the same test: an expanded disclosure must not stretch
|
|
its collapsed siblings, and the two contexts must not silently converge.
|
|
"""
|
|
html = _code(client.get("/llm").text)
|
|
engines = re.search(r'<div class="([^"]*)" id="engines">', html)
|
|
assert engines is not None, "the engines grid left the page"
|
|
assert engines.group(1) == "grid cols-2", \
|
|
f"the engine grid changed shape (no alignment of its own, both columns): {engines.group(1)!r}"
|
|
css = client.get("/static/dash.css").text
|
|
strip_block = css[css.index(".strip {"):css.index("}", css.index(".strip {"))]
|
|
assert "align-items" not in strip_block, \
|
|
"the strip row keeps its own alignment — the row is not equalized"
|
|
assert "display: grid" in strip_block, "the strip stopped being a grid"
|
|
assert "gap: .85rem" in strip_block, "the strip row lost its gap"
|
|
assert "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" in strip_block, \
|
|
"the strip's auto-fit columns left — one engine would get a red half"
|
|
# the unchanged context, re-pinned here so the two cannot silently converge
|
|
system = _code(client.get("/system").text)
|
|
hosts_class = re.findall(r'<div class="([^"]*)" id="hosts">', system)
|
|
assert hosts_class == ["grid auto items-start"], \
|
|
f"the system hosts grid stopped at its own height: {hosts_class}"
|
|
assert ".grid.items-start" in css, \
|
|
"the rule the system hosts grid relies on is gone"
|
|
|
|
|
|
def test_overview_is_exactly_two_panels(client) -> None:
|
|
"""The landing page answers one question: what is wrong, and what is the fleet doing.
|
|
|
|
Asserted on the served bytes because there is no JS test runner (a locked anchor):
|
|
the summary cards and the raw event feed are what made this page busy, and once a
|
|
panel is gone a browser is the only thing that would notice its return.
|
|
"""
|
|
html = client.get("/").text
|
|
assert html.count("<section") == 2, "the overview grew a third panel"
|
|
assert 'id="alerts"' in html and 'id="alerts-clear"' in html
|
|
assert 'aria-live="polite"' in html
|
|
assert 'id="fleet-table"' in html
|
|
for gone in ("sys-cpu", "sys-mem", "sys-disk", "sys-gpus", "llm-decode", "llm-kv",
|
|
"ups-list", 'id="events"'):
|
|
assert gone not in html, f"the overview still renders {gone}"
|
|
# the header's source dots are chrome owned by app.js, not the deleted card dots
|
|
assert 'id="dot-system"' not in html and 'id="nav-status"' in html
|
|
|
|
|
|
def test_overview_alert_rows_link_to_the_dashboard_that_explains_them(client) -> None:
|
|
"""Every alert carries the `href` its row turns into a link; the page invents none."""
|
|
page_js = client.get("/static/page-index.js").text
|
|
assert "alert.href" in page_js
|
|
css = client.get("/static/dash.css").text
|
|
for selector in ("ul.alerts li.ok .src", "ul.alerts li.warn .src", "ul.alerts li.fail .src",
|
|
"table.data td.warn", "table.data td.fail"):
|
|
assert selector in css, f"dash.css lost {selector}"
|
|
# the swap that puts a live problem above the fleet table
|
|
assert 'data-state="alerting"' in css
|
|
|
|
|
|
def test_overview_dims_the_fleet_row_of_a_host_that_stopped_answering(client) -> None:
|
|
"""A dead host keeps its last good numbers, so the row has to say they are stale.
|
|
|
|
`AGENTS.md` invariant 4: keep the payload and mark it stale, never zero it. The
|
|
overview's table is the one place those last good numbers still print, so it reads the
|
|
per-source verdicts `/api/summary` sends (`system.states`) and applies the shared
|
|
`.stale` class the other pages already use — no new palette, no JS threshold.
|
|
"""
|
|
page_js = client.get("/static/page-index.js").text
|
|
assert "system.states" in page_js, "the fleet table stopped reading reachability"
|
|
assert 'class="stale"' in page_js
|
|
assert ".stale" in client.get("/static/dash.css").text
|
|
|
|
|
|
# Which script owns which page, for the orphan check below. `charts.js` and `app.js` are
|
|
# loaded by every page, so an id either page code or shared chrome may read.
|
|
PAGE_SCRIPTS = {
|
|
"/": "page-index.js",
|
|
"/system": "page-system.js",
|
|
"/disks": "page-disks.js",
|
|
"/llm": "page-llm.js",
|
|
"/ups": "page-ups.js",
|
|
# `/gateway` (phase 29 task 04): the pipe map is built entirely in JS over the payload, so
|
|
# every `id` the page ships is written by this script or by a stylesheet rule.
|
|
GATEWAY_PAGE: "page-gateway.js",
|
|
# `/settings` is audited like a dashboard even though it is not one: an id nothing reads is
|
|
# dead markup whatever page it sits on, and this page has five new ones.
|
|
SETTINGS_PAGE: "page-settings.js",
|
|
# `/certs` too (phase 15 task 05): an authority panel and an agents table are markup, and
|
|
# every id in them is written by this page's own script.
|
|
CERTS_PAGE: "page-certs.js",
|
|
}
|
|
# `/login` is deliberately **not** in this table even though it is audited (phase 11): a
|
|
# signed-in client asking for it gets a 303 to the dashboard, so this helper's own
|
|
# redirect-following `get` would audit `/`'s markup and call it `/login`. The login page's
|
|
# orphan-`id`, CDN and masthead checks live in `tests/test_auth_api.py`, fetched unsigned, and
|
|
# `/static/page-login.js` is in `ASSETS` above.
|
|
|
|
SHARED_SCRIPTS = ("app.js", "charts.js")
|
|
|
|
|
|
def test_no_page_carries_an_id_nothing_reads(client) -> None:
|
|
"""An id no script queries, no stylesheet styles and no attribute points at is dead markup.
|
|
|
|
The summary cards were retired this way — a `<span id="sys-cpu">` left behind after its
|
|
writer was deleted is the same mistake in slower motion. Asserted on served bytes: the
|
|
browser console is otherwise the only thing that would notice.
|
|
"""
|
|
scripts = {name: client.get(f"/static/{name}").text for name in
|
|
(*SHARED_SCRIPTS, *PAGE_SCRIPTS.values(), "dash.css")}
|
|
for path, own_script in PAGE_SCRIPTS.items():
|
|
html = _code(client.get(path).text)
|
|
corpus = "".join(_code(scripts[name])
|
|
for name in (*SHARED_SCRIPTS, own_script, "dash.css"))
|
|
# ids the markup uses itself: labels, targets, `<label for>`
|
|
anchors = set(re.findall(
|
|
r'(?:aria-labelledby|aria-describedby|aria-controls|for)="([^"]+)"', html))
|
|
for element_id in re.findall(r'id="([^"]+)"', html):
|
|
quoted = (f'"{element_id}"' in corpus or f"'{element_id}'" in corpus
|
|
or f"#{element_id}" in corpus or element_id in anchors)
|
|
assert quoted, f"{path} has id=\"{element_id}\" that nothing reads"
|
|
|
|
|
|
# ---- the flavour-text audit (phase 08 task 02) ----------------------------
|
|
#
|
|
# `TODO.md:7`: "Get rid of most of the flavor text and add a note to AGENTS.md to not add it
|
|
# unless necessary. It just comes off as LLM generated slop, not helpful information." The
|
|
# test is one question per string — *would the operator act differently without it?* — and
|
|
# the answer decided the deletion. Copy that explains how the app builds its numbers is not
|
|
# helpful information; a provenance tag, a measured Δ window, and the sentence beside an
|
|
# empty panel are. `AGENTS.md` → *UI conventions* now carries the rule; the pins below are
|
|
# what stop the deleted sentences crawling back, because a screenshot only shows them once
|
|
# somebody already knows to look.
|
|
|
|
#: Fragments deleted from the served pages. The first two are the lines the operator quoted.
|
|
DELETED_FLAVOUR = (
|
|
# the overview's footer
|
|
"every number is scraped from the box",
|
|
"Sources the operator never configured are not problems",
|
|
# /system's "Per host" sub-line and its footer
|
|
"agent-reported values",
|
|
"each panel's charts cover only that host",
|
|
"numbers are measured, not inferred",
|
|
# /llm's footer, the one half-sentence of it that lived in the script too
|
|
"each throughput number carries its provenance",
|
|
"rates over the Δ window in each tag",
|
|
"a server you do not run",
|
|
# /ups's footer and its empty state
|
|
"commands are the only write path in this app",
|
|
"The dashboard speaks the NUT protocol",
|
|
# the three event feeds' header captions, all duplicated by an empty-state sentence
|
|
"unreachable / reachable / reboots",
|
|
"engine up / down",
|
|
"state changes, self-test results, commands sent",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("fragment", DELETED_FLAVOUR)
|
|
def test_no_page_narrates_its_own_pipeline(client, fragment: str) -> None:
|
|
"""The deleted copy is gone from every served byte, markup and script alike.
|
|
|
|
Asserted across all four pages and all five scripts rather than the file it came from:
|
|
the same sentence migrating into another page's card is the same slop, and a pin that
|
|
only guards its original file would let that through.
|
|
"""
|
|
for path in (*MARKUP_PAGES, *ASSETS):
|
|
body = client.get(path).text
|
|
assert fragment not in body, f"{path} narrates the app again: {fragment!r}"
|
|
|
|
|
|
def test_pages_leave_no_empty_narration_container(client) -> None:
|
|
"""A deleted sub-line removes its container, not just its words.
|
|
|
|
An empty `<span class="sub">` is markup that reserves a line for nothing — the page head
|
|
renders a gap where the sentence used to be. Elements the scripts fill (`#sub`,
|
|
`#alerts-count`) carry an `id` and are not in this category.
|
|
"""
|
|
for path in MARKUP_PAGES:
|
|
html = _code(client.get(path).text)
|
|
for cls in ("sub", "faint", "mono"):
|
|
assert f'<span class="{cls}"></span>' not in html, \
|
|
f"{path} keeps an empty .{cls} container"
|
|
|
|
|
|
def test_the_copy_the_operator_acts_on_survives_the_audit(client) -> None:
|
|
"""The other half of the deletion: functional copy stays.
|
|
|
|
Stripping flavour must not become stripping information. Every string here answers a
|
|
question the operator is asking while looking at the page — why is this panel empty,
|
|
is this number a measurement, is this battery real, is this host's data last-good —
|
|
and each one is the reason the audit deletes sentences and not labels.
|
|
"""
|
|
pages = {path: client.get(path).text for path in PAGES}
|
|
scripts = {name: client.get(f"/static/{name}").text for name in PAGE_SCRIPTS.values()}
|
|
|
|
# an empty panel says why, in terms the operator can act on
|
|
assert "No hosts configured" in pages["/"]
|
|
assert "HOST_TARGETS" in pages["/system"]
|
|
# …including the fourth page's: a `n/a` drive card is only actionable if the page names
|
|
# the two things that make SMART readable (the binary and the device-read grant).
|
|
assert "smartmontools" in pages["/disks"] + scripts["page-disks.js"]
|
|
assert "disk group" in scripts["page-disks.js"]
|
|
assert "No engines configured" in pages["/llm"]
|
|
# the not-configured arm is actionable only if it names the variable for every engine
|
|
for name in ("GUFO_BASE_URL", "VLLM_BASE_URL", "RADIANCE_BASE_URL"):
|
|
assert name in pages["/llm"], f"/llm's empty state stopped naming {name}"
|
|
assert "No UPS reporting" in pages["/ups"]
|
|
assert "instcmds" in pages["/ups"], "the UPS setup note stopped naming the grant that matters"
|
|
assert "3493" in pages["/ups"], "the page stopped warning that upsd's port is not always 3493"
|
|
# …and the sixth dashboard's empty state names the two things that make it read at all: the
|
|
# `HOST_TARGETS` name of the machine that routes, and an agent new enough to have read the
|
|
# proxy configs off it. A page that said "nothing to show" without either is not actionable.
|
|
assert "GATEWAY_HOST" in pages[GATEWAY_PAGE]
|
|
assert "HOST_TARGETS" in pages[GATEWAY_PAGE]
|
|
# …and the setup copy names the grant NUT actually checks. `README.md` is explicit: a
|
|
# dashboard user gets `instcmds`, never `actions = -`, so a page that told the operator
|
|
# to write that was sending them to fix the wrong line in `upsd.users`.
|
|
assert "actions = -" not in pages["/ups"] + scripts["page-ups.js"] + scripts["page-index.js"]
|
|
assert "instcmds" in scripts["page-ups.js"] and "instcmds" in scripts["page-index.js"]
|
|
|
|
# a Δ label is a measurement, and the rate's provenance travels with the rate
|
|
assert "windowLabel(" in scripts["page-system.js"]
|
|
assert "Δ" in scripts["page-llm.js"] and "function provenance(" in scripts["page-llm.js"]
|
|
assert "at the current load" in scripts["page-ups.js"]
|
|
|
|
# modelled data is labelled, and so is a feed that cannot contain what you are hunting
|
|
assert "SIMULATED" in scripts["page-index.js"] and "SIMULATED" in scripts["page-ups.js"]
|
|
assert "queued (deferred) requests are not in /slots" in scripts["page-llm.js"]
|
|
assert "Last good values" in scripts["page-index.js"]
|
|
|
|
|
|
def test_api_responses_are_not_cacheable(client) -> None:
|
|
for path in ("/api/config", "/api/health", "/api/summary"):
|
|
assert "no-store" in client.get(path).headers.get("cache-control", "")
|
|
|
|
|
|
# ---- history -------------------------------------------------------------- #
|
|
|
|
def test_history_returns_seeded_samples(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
db = str(tmp_path / "hist.sqlite3")
|
|
store = SampleStore(db)
|
|
now = time.time()
|
|
for i in range(30):
|
|
store.add("host", "driveripper", "cpu.usage_pct", 10.0 + i, ts=now - 300 + i)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
|
with TestClient(app) as client:
|
|
sign_in(client)
|
|
body = client.get("/api/history", params={"source": "host", "host": "driveripper",
|
|
"metric": "cpu.usage_pct", "range": "15m"})
|
|
assert response_ok(body)
|
|
points = body.json()["points"]
|
|
assert len(points) >= 25
|
|
assert points[-1]["value"] == pytest.approx(39.0)
|
|
store.close()
|
|
|
|
|
|
def test_history_rejects_an_unknown_range(client) -> None:
|
|
response = client.get("/api/history", params={"source": "host", "host": "h",
|
|
"metric": "m", "range": "banana"})
|
|
assert response.status_code == 400
|
|
|
|
|
|
def test_history_requires_the_series_coordinates(client) -> None:
|
|
assert client.get("/api/history").status_code == 422
|
|
|
|
|
|
@pytest.mark.parametrize(("raw", "seconds"), [
|
|
*[(key, float(seconds)) for key, seconds in RANGE_SECONDS.items()],
|
|
("45s", 45.0), ("13h", 46_800.0), ("7200", 7_200.0), (" 2D ", 172_800.0),
|
|
(None, 3_600.0), ("", 3_600.0),
|
|
])
|
|
def test_parse_range_reads_every_window_the_ui_offers(raw: Any, seconds: float) -> None:
|
|
"""Every chip key (5m…90d), any `<n><s|m|h|d>`, raw seconds, and the 1h default.
|
|
|
|
`parse_range` is the only thing between a typo'd query string and a chart of the wrong
|
|
window, and a wrong window is worse than an error: it still looks like data.
|
|
"""
|
|
assert parse_range(raw) == seconds
|
|
|
|
|
|
@pytest.mark.parametrize("raw", ["banana", "1w", "h", "-4h", "one"])
|
|
def test_parse_range_refuses_a_window_it_cannot_interpret(raw: str) -> None:
|
|
"""A 400 that names the value, never a silent fallback to 1h."""
|
|
with pytest.raises(HTTPException) as excinfo:
|
|
parse_range(raw)
|
|
assert excinfo.value.status_code == 400
|
|
assert raw in str(excinfo.value.detail)
|
|
|
|
|
|
# ---- the routes the *other* pages chart from -------------------------------- #
|
|
#
|
|
# `/` renders two panels now; `/system`, `/llm` and `/ups` still need these, and so does
|
|
# anything outside the browser. Retiring overview code must not retire them, so the shapes
|
|
# are pinned here rather than discovered by a page three phases from now.
|
|
|
|
def test_system_history_charts_every_configured_host_in_one_request(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""N hosts x M metrics, one round trip.
|
|
|
|
`/system` no longer uses the N-hosts half of this (every chart belongs to one host and
|
|
the page issues one request per host, pinned by
|
|
`test_every_system_chart_is_drawn_inside_its_own_host_panel`); the shape is kept because
|
|
the metric-list half is exactly what the per-host query costs one request with, and
|
|
because the endpoint is a public interface for programmatic consumers.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
host_targets=(Target("driveripper", DEAD_URL),
|
|
Target("deskwork", DEAD_URL))) as test_client:
|
|
body = test_client.get("/api/system/history",
|
|
params={"metrics": "cpu.usage_pct,mem.used_pct",
|
|
"range": "1h"}).json()
|
|
assert body["hosts"] == ["driveripper", "deskwork"]
|
|
assert body["range_seconds"] == 3_600.0
|
|
assert len(body["series"]) == 4
|
|
assert {(item["host"], item["metric"]) for item in body["series"]} == {
|
|
("driveripper", "cpu.usage_pct"), ("driveripper", "mem.used_pct"),
|
|
("deskwork", "cpu.usage_pct"), ("deskwork", "mem.used_pct")}
|
|
# an explicit `hosts` list narrows it instead of adding to the default
|
|
narrowed = test_client.get("/api/system/history",
|
|
params={"metrics": "cpu.usage_pct",
|
|
"hosts": "deskwork"}).json()
|
|
assert narrowed["hosts"] == ["deskwork"]
|
|
|
|
|
|
def test_one_request_per_host_answers_every_per_host_chart(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The exact query `/system` issues per host returns every series that panel draws.
|
|
|
|
`page-system.js` sends one request per host carrying the utilisation trio
|
|
(`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct`), both network rates, and one
|
|
`gpu.busy_pct@<index>` per card the payload reported — so a name missing here is a chart
|
|
that silently reads "no samples yet" in the browser, and a renamed series in
|
|
`sources/host.py` fails here rather than in a screenshot. This is the metric-name half of
|
|
the contract; `tests/test_system_contract.py` holds the payload-path half.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
|
|
store = test_client.app.state.ctx.store # type: ignore[attr-defined]
|
|
now = time.time()
|
|
metrics = ["cpu.usage_pct", "mem.used_pct", "disk.used_pct",
|
|
"net.rx_bytes_per_s", "net.tx_bytes_per_s", "gpu.busy_pct@0"]
|
|
for metric in metrics:
|
|
store.add("host", "driveripper", metric, 12.5, ts=now - 60)
|
|
store.add("host", "driveripper", metric, 25.0, ts=now - 30)
|
|
|
|
body = test_client.get("/api/system/history", params={
|
|
"hosts": "driveripper", "metrics": ",".join(metrics), "range": "1h"}).json()
|
|
assert body["hosts"] == ["driveripper"], "one panel, one host"
|
|
assert [item["metric"] for item in body["series"]] == metrics
|
|
assert [len(item["points"]) for item in body["series"]] == [2] * len(metrics)
|
|
|
|
# a second card the host does not have answers empty, never zero and never 404
|
|
gap = test_client.get("/api/system/history",
|
|
params={"hosts": "driveripper", "metrics": "gpu.busy_pct@1"}).json()
|
|
assert gap["series"][0]["points"] == []
|
|
|
|
|
|
def test_every_history_endpoint_answers_exactly_what_the_per_series_path_did(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Phase 14 task 02: the batched store door moved the cost of an N x M request, not its answer.
|
|
|
|
All four history routes now ask `store.history_many` for the whole batch instead of looping
|
|
`store.history` per pair. That is only safe if the JSON is the same document the loop built,
|
|
so this compares every route against points read straight out of the store with `query()` —
|
|
a third path, in neither the loop nor the batch — and pins the series order the pages parse
|
|
(host-major, metric-minor) plus the exact key set of one series entry.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
|
|
host_targets=(Target("driveripper", DEAD_URL),
|
|
Target("deskwork", DEAD_URL)),
|
|
gufo_url=DEAD_URL) as test_client:
|
|
store = test_client.app.state.ctx.store # type: ignore[attr-defined]
|
|
now = time.time()
|
|
writes = [
|
|
("host", "driveripper", "cpu.usage_pct", [1.0, 2.0, 3.0]),
|
|
("host", "driveripper", "mem.used_pct", [10.0, 20.0]),
|
|
("host", "deskwork", "cpu.usage_pct", [4.0, 5.0, 6.0]),
|
|
("host", "deskwork", "mem.used_pct", [30.0]),
|
|
("disks", "driveripper", "disk.temp_c@nvme0n1", [38.0, 39.0]),
|
|
("llm", "gufo", "llm.decode_tokens_per_second", [40.0, 45.0]),
|
|
# a UPS name the simulator does not export, so its 5-second poll cannot add rows to
|
|
# the series this assertion is about
|
|
("ups", "notaserial", "ups.charge_pct", [99.0, 100.0]),
|
|
]
|
|
for source, host, metric, values in writes:
|
|
store.add_many([Sample(source, host, metric, value, now - (len(values) - index) * 60)
|
|
for index, value in enumerate(values)])
|
|
|
|
def expected(source: str, host: str, metric: str) -> list[dict[str, float]]:
|
|
rows = store.query(source, host, metric, res=RES_RAW, start=now - 3_600.0, tail=True)
|
|
return [{"ts": ts, "value": value} for ts, value in rows]
|
|
|
|
routes = [
|
|
("/api/system/history", {"hosts": "driveripper,deskwork",
|
|
"metrics": "cpu.usage_pct,mem.used_pct", "range": "1h"},
|
|
[("host", host, metric) for host in ("driveripper", "deskwork")
|
|
for metric in ("cpu.usage_pct", "mem.used_pct")]),
|
|
("/api/disks/history", {"hosts": "driveripper",
|
|
"metrics": "disk.temp_c@nvme0n1", "range": "1h"},
|
|
[("disks", "driveripper", "disk.temp_c@nvme0n1")]),
|
|
("/api/llm/history", {"engines": "gufo", "metrics": "decode_tokens_per_second",
|
|
"range": "1h"},
|
|
[("llm", "gufo", "llm.decode_tokens_per_second")]),
|
|
("/api/ups/history", {"ups": "notaserial", "metrics": "ups.charge_pct",
|
|
"range": "1h"},
|
|
[("ups", "notaserial", "ups.charge_pct")]),
|
|
]
|
|
for path, params, pairs in routes:
|
|
body = test_client.get(path, params=params).json()
|
|
assert len(body["series"]) == len(pairs), path
|
|
for series, (source, host, metric) in zip(body["series"], pairs, strict=True):
|
|
assert set(series) == {"source", "host", "metric", "range_seconds", "points"}
|
|
assert (series["source"], series["host"], series["metric"]) == (source, host, metric)
|
|
assert series["range_seconds"] == 3_600.0
|
|
assert series["points"] == expected(source, host, metric), f"{path} {metric}"
|
|
|
|
# and an empty answer is still an answer: a name with no rows, in a batch with names that
|
|
# do, comes back as an empty series rather than a missing entry or a 404
|
|
body = test_client.get("/api/system/history", params={
|
|
"hosts": "driveripper", "metrics": "cpu.usage_pct,gpu.busy_pct@7",
|
|
"range": "1h"}).json()
|
|
assert [series["points"] for series in body["series"]] == [expected(
|
|
"host", "driveripper", "cpu.usage_pct"), []]
|
|
|
|
# no metric named is no series asked for: the batch door is never opened with an empty
|
|
# list (SQL `IN ()` is a syntax error waiting for that, and 500 is not the answer anyway)
|
|
empty = test_client.get("/api/system/history",
|
|
params={"hosts": "driveripper", "metrics": ""}).json()
|
|
assert empty["hosts"] == ["driveripper"] and empty["series"] == []
|
|
no_hosts = test_client.get("/api/system/history",
|
|
params={"hosts": "", "metrics": "cpu.usage_pct"}).json()
|
|
assert no_hosts["hosts"] == ["driveripper", "deskwork"], "empty means every configured host"
|
|
assert len(no_hosts["series"]) == 2
|
|
|
|
|
|
def test_llm_snapshot_keeps_an_engine_that_never_answered_in_the_list(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A configured-but-dead engine is listed as unreachable, not dropped (invariant 4)."""
|
|
# a host target too, so the snapshot's job filter has to skip a non-LLM job
|
|
with build_client(tmp_path, monkeypatch, vllm_url=DEAD_URL,
|
|
host_targets=(Target("driveripper", DEAD_URL),)) as test_client:
|
|
body = test_client.get("/api/llm/snapshot").json()
|
|
assert [engine["engine"] for engine in body["engines"]] == ["vLLM"]
|
|
assert body["engines"][0]["unreachable"] is True
|
|
assert body["fleet"]["engines_total"] == 1
|
|
assert body["fleet"]["engines_live"] == 0
|
|
assert [state["key"] for state in body["states"]] == ["llm:vllm"]
|
|
|
|
|
|
def test_llm_history_charts_both_engines_by_default(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
|
|
vllm_url=DEAD_URL) as test_client:
|
|
body = test_client.get("/api/llm/history",
|
|
params={"metrics": "decode_tokens_per_second,kv_cache_usage_ratio",
|
|
"range": "15m"}).json()
|
|
assert body["engines"] == ["gufo", "vllm"]
|
|
assert body["range_seconds"] == 900.0
|
|
assert {(item["host"], item["metric"]) for item in body["series"]} == {
|
|
("gufo", "llm.decode_tokens_per_second"),
|
|
("gufo", "llm.kv_cache_usage_ratio"),
|
|
("vllm", "llm.decode_tokens_per_second"),
|
|
("vllm", "llm.kv_cache_usage_ratio")}
|
|
|
|
|
|
def test_one_batched_llm_history_request_answers_every_cell_series(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The exact query `/llm` issues per refresh returns every chart *and* sparkline series.
|
|
|
|
Phase 04 sends one request carrying the chart trio and the whole spark cell list —
|
|
including the two series the layout required (`tpot_seconds` for the vLLM TPOT row,
|
|
`spec_accept_rate` for the gufo spec row). A name missing here is a cell that reads
|
|
"no history" forever in the browser, which is the dead-cell anti-pattern the phase
|
|
wrote NOTES-chart-set to prevent; a rename in `sources/llm.py` fails here first.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, gufo_url=DEAD_URL,
|
|
vllm_url=DEAD_URL) as test_client:
|
|
store = test_client.app.state.ctx.store # type: ignore[attr-defined]
|
|
now = time.time()
|
|
metrics = ["decode_tokens_per_second", "prompt_tokens_per_second",
|
|
"kv_cache_usage", "requests_running", "requests_waiting",
|
|
"ttft_seconds", "tpot_seconds", "queue_seconds",
|
|
"prefix_cache_hit_ratio", "spec_accept_rate"]
|
|
for metric in metrics:
|
|
store.add("llm", "gufo", f"llm.{metric}", 1.5, ts=now - 60)
|
|
store.add("llm", "vllm", f"llm.{metric}", 2.5, ts=now - 30)
|
|
body = test_client.get("/api/llm/history", params={
|
|
"engines": "gufo,vllm", "metrics": ",".join(metrics), "range": "1h"}).json()
|
|
assert body["range_seconds"] == 3_600.0
|
|
by_key = {(item["host"], item["metric"]) for item in body["series"]}
|
|
assert by_key == {(engine, f"llm.{metric}")
|
|
for engine in ("gufo", "vllm") for metric in metrics}
|
|
points = {(item["host"], item["metric"]): item["points"] for item in body["series"]}
|
|
assert points[("gufo", "llm.spec_accept_rate")][0]["value"] == pytest.approx(1.5)
|
|
assert points[("vllm", "llm.tpot_seconds")][0]["value"] == pytest.approx(2.5)
|
|
|
|
|
|
def test_metrics_endpoint_lists_what_actually_has_history(client) -> None:
|
|
body = client.get("/api/metrics").json()
|
|
assert body["metrics"] == []
|
|
|
|
|
|
# ---- the `/disks` API (phase 09 task 03) ---------------------------------- #
|
|
#
|
|
# The drive-health surface answers like every other dashboard's: GETs only, the same
|
|
# stale/stub semantics for a host that is not answering, the same history mechanism for the
|
|
# `disk.*@dev` series, and the same 404-`configured: false` sentence when no agent is
|
|
# configured at all — the last one *without dialling*, because "not deployed here" must not
|
|
# cost a connect timeout or a red dot (invariant 3).
|
|
|
|
@pytest.fixture
|
|
def disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
disk_snapshot: Any) -> Iterator[TestClient]:
|
|
"""Two hosts configured; one of them answering SMART, through the production parser.
|
|
|
|
The payload and the stored samples are what `parse_disks_snapshot` makes of the
|
|
fixture-shaped capture (the agent's whole-disk rows plus the recorded `smartctl` shapes),
|
|
so every path a `/disks` card will read is a path the source really emits. The other host
|
|
keeps its `unreachable` stub, which is the shape a dead agent must keep (invariant 4).
|
|
Both targets point at a dead port, so nothing dials.
|
|
"""
|
|
db = str(tmp_path / "disks.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("driveripper", DEAD_URL),
|
|
Target("deskwork", DEAD_URL))), store)
|
|
with TestClient(app) as test_client:
|
|
sign_in(test_client)
|
|
now = time.time()
|
|
snapshot = disk_snapshot("driveripper")
|
|
snapshot["ts"] = now # "the agent answered just now", so charts are in range
|
|
result = parse_disks_snapshot("driveripper", snapshot, DisksCollector(), now=now)
|
|
store.add_many(result.samples)
|
|
app.state.ctx.hub.jobs["disks:driveripper"].state.payload = result.payload
|
|
yield test_client
|
|
store.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def mixed_disks_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
disk_snapshot: Any) -> Iterator[TestClient]:
|
|
"""Four configured hosts: one per answer an agent can give about `smartctl`, plus a stub.
|
|
|
|
Phase 22's visibility gate needs all three arms of `smartctl_available` in **one** fleet,
|
|
because "hidden" only means something against what stays:
|
|
|
|
* `driveripper` — every drive read: `smartctl_available: true`
|
|
* `deskwork` — every drive `null` with no error line: `null` (`--no-smart`, or an agent
|
|
predating the SMART pass — it answered, it just cannot say)
|
|
* `gateway` — every drive `null` plus the agent's own "not found" line: `false`, which is
|
|
the one arm that hides a host (`api._disk_visible`)
|
|
* `deadhost` — never answered: the `unreachable` stub, whose flag is `null` by shape
|
|
|
|
Each payload is `parse_disks_snapshot`'s own output over the fixture captures, and every
|
|
target points at a dead port, so nothing dials and no poll can overwrite what is set here.
|
|
"""
|
|
db = str(tmp_path / "mixed-disks.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("driveripper", DEAD_URL),
|
|
Target("deskwork", DEAD_URL),
|
|
Target("gateway", DEAD_URL),
|
|
Target("deadhost", DEAD_URL))), store)
|
|
with TestClient(app) as test_client:
|
|
sign_in(test_client)
|
|
now = time.time()
|
|
for name, mode in (("driveripper", "healthy"), ("deskwork", "silent"),
|
|
("gateway", "no_binary")):
|
|
snapshot = disk_snapshot(name, mode=mode)
|
|
snapshot["ts"] = now # "the agent answered just now"
|
|
result = parse_disks_snapshot(name, snapshot, DisksCollector(), now=now)
|
|
app.state.ctx.hub.jobs[f"disks:{name}"].state.payload = result.payload
|
|
yield test_client
|
|
store.close()
|
|
|
|
|
|
#: (`smart` field, severity metric) for the two drive numbers the table rates — the same
|
|
#: pair `sources/disks.py:SEVERITY_FIELDS` declares, restated here so a rename that only
|
|
#: touches one side fails rather than colouring nothing.
|
|
RATED_CELLS: tuple[tuple[str, str], ...] = (
|
|
("temp_c", "disk.temp_c"),
|
|
("percentage_used", "disk.percentage_used"),
|
|
)
|
|
|
|
|
|
def disk_rows(body: dict[str, Any], host: str = "driveripper") -> list[dict[str, Any]]:
|
|
"""The disk rows of one host out of a `/api/disks/snapshot` body."""
|
|
return next(row for row in body["hosts"] if row["name"] == host)["disks"]
|
|
|
|
|
|
def flat_routes(app: Any) -> list[Any]:
|
|
"""Every route the app serves, with `include_router()` results flattened to their routes.
|
|
|
|
The FastAPI this repo pins keeps an included router as an `_IncludedRouter` wrapper with
|
|
no `path` of its own, so walking `app.routes` alone sees five anonymous objects instead of
|
|
the API surface — and the verb assertion below is only as good as this walk.
|
|
"""
|
|
out: list[Any] = []
|
|
for route in app.routes:
|
|
inner = getattr(route, "original_router", None)
|
|
out.extend(inner.routes if inner is not None else [route])
|
|
return out
|
|
|
|
|
|
def series_source(body: dict[str, Any]) -> set[str]:
|
|
"""Which sample family a history answer came from: `disks`, never the 10-second `host`."""
|
|
return {series["source"] for series in body["series"]}
|
|
|
|
|
|
def test_disks_snapshot_answers_one_entry_per_host_with_its_physical_disks(
|
|
disks_client) -> None:
|
|
"""`GET /api/disks/snapshot` is per host: rows + `smart` + `smartctl_available`.
|
|
|
|
"Per host" means per **visible** host since phase 22 — this fleet has no `false` host in
|
|
it, so both stub and answering host are listed; the hidden case is pinned on a fleet that
|
|
has one (`mixed_disks_client`).
|
|
"""
|
|
body = disks_client.get("/api/disks/snapshot")
|
|
assert response_ok(body)
|
|
payload = body.json()
|
|
assert [host["name"] for host in payload["hosts"]] == ["deskwork", "driveripper"], (
|
|
"every configured host is a row, in name order, answering or not")
|
|
rows = disk_rows(payload)
|
|
assert rows, "the capture has 16 whole-disk devices, so the page has 16 cards"
|
|
assert all("/" not in row["device"] for row in rows), "a mount leaked into the disk list"
|
|
first = rows[0]
|
|
assert first["path"] == f"/dev/{first['device']}"
|
|
assert set(first) >= {"device", "path", "model", "size_bytes", "rotational",
|
|
"health", "smart", "severity"}
|
|
assert first["smart"]["power_on_hours"] > 0, "the drive's own counters travel verbatim"
|
|
live = next(host for host in payload["hosts"] if host["name"] == "driveripper")
|
|
assert live["smartctl_available"] is True
|
|
assert live["smart_errors"] == []
|
|
assert live["summary"]["disks"] == len(rows)
|
|
assert "unreachable" not in live, "answering is the absence of the flag, as on /system"
|
|
assert [state["key"] for state in payload["states"]] == ["disks:driveripper",
|
|
"disks:deskwork"], (
|
|
"the page's dots come from the disks jobs, never the system ones")
|
|
|
|
|
|
def test_disks_snapshot_carries_the_verdicts_the_page_will_paint(disks_client) -> None:
|
|
"""Colour is in the payload: each row's `severity` is what `severity.classify` says.
|
|
|
|
This is the pin that keeps the threshold out of the page — `page-disks.js` reads these
|
|
and never compares a drive temperature to a bound.
|
|
"""
|
|
rows = disk_rows(disks_client.get("/api/disks/snapshot").json())
|
|
assert "ok" in {row["severity"]["temp_c"] for row in rows}, "the capture is healthy"
|
|
for row in rows:
|
|
smart = row["smart"] or {}
|
|
assert set(row["severity"]) == {field for field, _metric in RATED_CELLS} | {"health"}
|
|
for field, metric in RATED_CELLS:
|
|
assert row["severity"][field] == classify(metric, smart.get(field)), (
|
|
f"{row['device']}.{field} is coloured by something other than the one table")
|
|
assert not {key for row in rows for key in row["severity"]} & {
|
|
"reallocated_sector_ct", "pending", "uncorrectable", "power_on_hours"}, (
|
|
"a counter is an alarm chip on presence, never a graded cell")
|
|
|
|
|
|
def test_a_configured_host_with_no_payload_yet_is_a_row_of_nas(disks_client) -> None:
|
|
"""The stub shape `/api/system/snapshot` hands out, so `n/a` is never a missing row.
|
|
|
|
A host in `HOST_TARGETS` that has not answered is still in the fleet (invariants 3 and
|
|
4) with its counts `None` rather than `0`: "unknown" and "no drives" are different
|
|
sentences (rule 2), and the fleet header counts it without summing it.
|
|
"""
|
|
payload = disks_client.get("/api/disks/snapshot").json()
|
|
stub = next(host for host in payload["hosts"] if host["name"] == "deskwork")
|
|
assert stub["unreachable"] is True
|
|
assert stub["disks"] == [], "nothing to draw, but the host is still listed"
|
|
assert stub["smartctl_available"] is None, "an agent that never answered says nothing"
|
|
assert stub["smart_errors"] == []
|
|
assert set(stub["summary"]) == {"disks", "with_smart", "health_failed", "alarm_disks"}
|
|
assert all(value is None for value in stub["summary"].values()), "no invented zeros"
|
|
fleet = payload["fleet"]
|
|
assert (fleet["hosts_total"], fleet["hosts_live"]) == (2, 1)
|
|
assert fleet["disks"] == len(disk_rows(payload))
|
|
|
|
|
|
def test_the_disks_fleet_header_is_summed_by_the_api_not_the_page(disks_client) -> None:
|
|
"""`fleet` is the header's only arithmetic source, and it carries no verdict."""
|
|
payload = disks_client.get("/api/disks/snapshot").json()
|
|
live = next(host for host in payload["hosts"] if host["name"] == "driveripper")
|
|
fleet = payload["fleet"]
|
|
assert fleet["disks"] == live["summary"]["disks"]
|
|
assert fleet["alarm_disks"] == live["summary"]["alarm_disks"] > 0, (
|
|
"the recorded ATA shape carries reallocated sectors, so the fleet has chips to draw")
|
|
assert "severity" not in fleet, "a count of drives is not a graded number"
|
|
|
|
|
|
# ---- the `/disks` visibility gate (phase 22 task 01) ----------------------- #
|
|
#
|
|
# `TODO.md:13`: "VMs that don't have smartctl enabled disks should not show up. Gateway
|
|
# shouldn't even be a host on the disks page." One predicate (`api._disk_visible`) over the
|
|
# three arms of `smartctl_available`, applied to the assembled host list in both endpoints, so
|
|
# the page's rows, that block's rows and the fleet sums cannot describe three fleets. The
|
|
# source, the store, the `disks:<host>` job and its health record still see every host.
|
|
|
|
def test_the_disk_visibility_gate_reads_only_the_explicit_false() -> None:
|
|
"""Three arms, one hide condition — and the test is `is False`, not falsy.
|
|
|
|
`None` means "nobody knows" (an agent predating the SMART pass, `--no-smart`, or a payload
|
|
with no such key at all) and an agent that cannot say is not an agent that said *no*; a
|
|
dead agent's stub stays too, because a machine that stopped answering must never read as
|
|
"never configured" (invariant 3).
|
|
"""
|
|
assert _disk_visible({"smartctl_available": True}) is True
|
|
assert _disk_visible({"smartctl_available": None}) is True
|
|
assert _disk_visible({}) is True, "a payload that never said anything is not a 'no'"
|
|
assert _disk_visible({"unreachable": True, "smartctl_available": None}) is True, (
|
|
"a dead agent is not a host without smartmontools")
|
|
assert _disk_visible({"smartctl_available": 0}) is True, (
|
|
"falsy is not the predicate — only the value the agent answered with")
|
|
assert _disk_visible({"smartctl_available": False}) is False
|
|
|
|
|
|
def test_a_host_whose_agent_has_no_smartctl_is_not_a_host_on_the_disks_page(
|
|
mixed_disks_client) -> None:
|
|
"""The absent host and the two `unknown` hosts, pinned in the same response.
|
|
|
|
gateway's VM answers "`smartctl` not found", so it has no card and no row; deskwork's
|
|
agent cannot say either way and deadhost's never answered, and both keep their dimmed
|
|
`n/a` rows exactly as before this gate existed.
|
|
"""
|
|
payload = mixed_disks_client.get("/api/disks/snapshot").json()
|
|
visible = {host["name"]: host for host in payload["hosts"]}
|
|
assert list(visible) == ["deadhost", "deskwork", "driveripper"], (
|
|
"only the host whose agent said 'no smartctl' is gone")
|
|
assert visible["driveripper"]["smartctl_available"] is True
|
|
assert visible["driveripper"]["disks"], "the host that stayed kept every drive row"
|
|
assert visible["deskwork"]["smartctl_available"] is None, (
|
|
"an agent that cannot say is still a card, with its own why-line")
|
|
assert "unreachable" not in visible["deskwork"], "it answered — silently, but it answered"
|
|
assert visible["deadhost"]["unreachable"] is True
|
|
assert visible["deadhost"]["smartctl_available"] is None
|
|
|
|
|
|
def test_the_disks_fleet_header_counts_only_the_hosts_the_page_lists(
|
|
mixed_disks_client) -> None:
|
|
"""`hosts_total` is the page's row count, and a hidden host lends it no drives."""
|
|
payload = mixed_disks_client.get("/api/disks/snapshot").json()
|
|
fleet = payload["fleet"]
|
|
rows_by_host = {host["name"]: host["disks"] for host in payload["hosts"]}
|
|
assert fleet["hosts_total"] == len(payload["hosts"]) == 3
|
|
assert fleet["hosts_live"] == 2, "the stub host is counted and never summed"
|
|
assert fleet["disks"] == len(rows_by_host["driveripper"]) + len(rows_by_host["deskwork"]), (
|
|
"the hidden host's `vda` is in no fleet count")
|
|
assert fleet["with_smart"] == len(rows_by_host["driveripper"])
|
|
assert "severity" not in fleet
|
|
|
|
|
|
def test_hiding_a_diskless_host_leaves_its_health_record_and_the_fleet_alone(
|
|
mixed_disks_client) -> None:
|
|
"""The gate reads a payload; it does not unwatch the machine.
|
|
|
|
`states` still lists `disks:gateway` (the job polls, and healthy is healthy), `/system`
|
|
still lists gateway as a host, and `/api/health` still answers for the job — a box with no
|
|
smartmontools is missing from the **drive** page and from nothing else (invariant 3).
|
|
"""
|
|
snapshot = mixed_disks_client.get("/api/disks/snapshot").json()
|
|
summary = mixed_disks_client.get("/api/summary").json()
|
|
assert {state["key"] for state in snapshot["states"]} == {
|
|
"disks:deadhost", "disks:deskwork", "disks:driveripper", "disks:gateway"}
|
|
assert "gateway" in {row["name"] for row in summary["system"]["hosts"]}, (
|
|
"the system page lost a machine over a missing binary")
|
|
health = {row["key"]: row for row in
|
|
mixed_disks_client.get("/api/health").json()["sources"]}
|
|
assert "disks:gateway" in health
|
|
|
|
|
|
def test_summary_and_the_disks_page_hide_the_same_hosts(mixed_disks_client) -> None:
|
|
"""One predicate, two endpoints: `/` and `/disks` may not list different machines."""
|
|
body = mixed_disks_client.get("/api/summary").json()
|
|
snapshot = mixed_disks_client.get("/api/disks/snapshot").json()
|
|
disks = body["disks"]
|
|
# the **set**, not the list: `/api/disks/snapshot` sorts by name, `/api/summary` answers
|
|
# rows in hub order and always has
|
|
assert {row["name"] for row in disks["hosts"]} == {
|
|
host["name"] for host in snapshot["hosts"]} == {
|
|
"deadhost", "deskwork", "driveripper"}
|
|
assert disks["fleet"] == snapshot["fleet"], "the header would disagree with the page"
|
|
assert disks["fleet"]["hosts_total"] == 3
|
|
assert disks["enabled"] is True
|
|
assert disks["fleet"]["hosts_total"] != len(body["system"]["hosts"]), (
|
|
"four machines configured, three drive rows — the two numbers are different facts")
|
|
# the rows that stayed carry the shape they always did, `unknown` and all (rule 2)
|
|
rows = {row["name"]: row for row in disks["hosts"]}
|
|
assert rows["deskwork"]["smartctl_available"] is None
|
|
assert rows["deskwork"]["unreachable"] is False
|
|
assert rows["deadhost"]["unreachable"] is True and rows["deadhost"]["disks"] is None
|
|
|
|
|
|
def test_the_disk_visibility_gate_applies_with_only_one_host_configured(
|
|
tmp_path: Path, disk_snapshot: Any) -> None:
|
|
"""A fleet of nothing but a `false` host is an empty drive page, not a 404.
|
|
|
|
Unconfigured still means "no `HOST_TARGETS`" (a 404 that dials nothing, invariant 3);
|
|
configured-and-hidden answers with an empty list and a zero fleet, which is what the page
|
|
renders as its empty state.
|
|
"""
|
|
db = str(tmp_path / "one-hidden-host.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("gateway", DEAD_URL),)), store)
|
|
with TestClient(app) as test_client:
|
|
sign_in(test_client)
|
|
now = time.time()
|
|
snapshot = disk_snapshot("gateway", mode="no_binary")
|
|
snapshot["ts"] = now
|
|
result = parse_disks_snapshot("gateway", snapshot, DisksCollector(), now=now)
|
|
assert result.payload["smartctl_available"] is False, "the arm this test is about"
|
|
app.state.ctx.hub.jobs["disks:gateway"].state.payload = result.payload
|
|
body = test_client.get("/api/disks/snapshot")
|
|
assert body.status_code == 200, "configured-and-hidden is not unconfigured"
|
|
payload = body.json()
|
|
assert payload["hosts"] == []
|
|
assert payload["fleet"]["hosts_total"] == 0
|
|
assert payload["fleet"]["disks"] == 0
|
|
assert [state["key"] for state in payload["states"]] == ["disks:gateway"]
|
|
summary = test_client.get("/api/summary").json()["disks"]
|
|
assert summary["enabled"] is True
|
|
assert summary["hosts"] == []
|
|
assert summary["fleet"] == payload["fleet"], (
|
|
"an all-hidden fleet must look the same from both endpoints")
|
|
store.close()
|
|
|
|
|
|
def test_the_disks_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
|
disks_client) -> None:
|
|
"""Invariant 1: the app writes to an **upstream** from exactly two named routes."""
|
|
routes = flat_routes(disks_client.app)
|
|
disks = [route for route in routes
|
|
if str(getattr(route, "path", "")).startswith("/api/disks")]
|
|
assert {route.path for route in disks} == {"/api/disks/snapshot", "/api/disks/history"}
|
|
verbs = {verb for route in disks for verb in (getattr(route, "methods", None) or set())}
|
|
assert verbs == {"GET"}, "a write appeared on the drive-health surface"
|
|
for verb in ("POST", "PUT", "PATCH", "DELETE"):
|
|
response = disks_client.request(verb, "/api/disks/snapshot", json={})
|
|
assert response.status_code == 405, f"{verb} /api/disks/snapshot was accepted"
|
|
writes = sorted(str(route.path) for route in routes
|
|
if "POST" in (getattr(route, "methods", None) or set()))
|
|
# Invariant 1 is about the fleet. The `/auth/` writes create, re-credential, and clear a
|
|
# session in this process; the `/certs/` writes (phase 15) mint, reissue, and revoke a
|
|
# certificate in this process's `meta` table. Neither dials anything — and a revocation's whole
|
|
# effect is that the *next poll* refuses to dial — so they are named here rather than filtered
|
|
# out of sight, because the failure this assertion catches is a POST arriving quietly.
|
|
authority = [path for path in writes if path.startswith("/api/certs/")]
|
|
session = [path for path in writes if path.startswith("/api/auth/")]
|
|
upstream = [path for path in writes
|
|
if not path.startswith(("/api/auth/", "/api/certs/"))]
|
|
assert upstream == ["/api/gateway/route", "/api/ups/command"], (
|
|
"the upstream write table changed — invariant 1 names exactly two exceptions")
|
|
assert session == ["/api/auth/login", "/api/auth/logout", "/api/auth/password"]
|
|
assert authority == ["/api/certs/root/rotate", "/api/certs/{name}/revoke",
|
|
"/api/certs/{name}/rotate"], "the CA surface grew a write nobody named"
|
|
# …and nothing anywhere grew the verbs this app has no use for, session included.
|
|
assert not sorted(path for route in routes
|
|
for path in [str(route.path)]
|
|
if set(getattr(route, "methods", None) or ()) & {"PUT", "PATCH", "DELETE"})
|
|
# The session writes are not upstream writes, but they are still the only ones: `/login`
|
|
# is the page, not a POST target, and `/api/auth/status` stays a read.
|
|
pages = sorted(str(route.path) for route in routes
|
|
if "GET" in (getattr(route, "methods", None) or set())
|
|
and not str(route.path).startswith("/api/"))
|
|
# `/settings` (phase 11) is a fifth dashboard-shaped **page** and it grew no verb: a page
|
|
# route is a GET, so the write surface this test guards stays the one UPS command. Same for
|
|
# `/gateway` (phase 29 task 04): the connection map is a read of one job's payload, and the
|
|
# app still dials an upstream on exactly one verb.
|
|
assert pages == ["/", "/certs", "/disks", "/gateway", "/llm", "/login", "/settings",
|
|
"/system", "/ups"]
|
|
|
|
|
|
def test_config_prints_the_disks_cadence_the_page_explains_itself_with(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A card four minutes old is behaving, not broken — and the interval is the proof.
|
|
|
|
`/disks` has no upstream of its own, so `enabled` follows `HOST_TARGETS` and the cadence
|
|
is its own. Both numbers come from config rather than a constant in the page, so changing
|
|
`DISKS_INTERVAL_SECONDS` moves the sentence and the poller together.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=(Target("driveripper", DEAD_URL),),
|
|
disks_interval=120.0, disks_timeout=45.0) as test_client:
|
|
disks = test_client.get("/api/config").json()["disks"]
|
|
assert disks["enabled"] is True
|
|
assert disks["interval_seconds"] == 120.0
|
|
assert disks["timeout_seconds"] == 45.0
|
|
|
|
|
|
def test_disks_snapshot_404s_without_dialling_a_host_that_was_never_configured(
|
|
client, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The gate runs before anything that could reach the network (invariant 3).
|
|
|
|
Both spies raise, so a route that constructed a client or fetched a snapshot before
|
|
checking `HOST_TARGETS` fails here instead of costing a timeout and a red dot. The hub
|
|
assertion is the structural half: with no targets there is no `disks` job to dial with.
|
|
"""
|
|
calls: list[str] = []
|
|
|
|
def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
calls.append(f"GET {url}")
|
|
raise AssertionError(f"the unconfigured disks route dialled {url}")
|
|
|
|
def spy_fetch(*args: Any, **kwargs: Any) -> Any:
|
|
calls.append("fetch_snapshot")
|
|
raise AssertionError("the unconfigured disks route fetched an agent snapshot")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
|
|
monkeypatch.setattr("dashboards.collectors.fetch_snapshot", spy_fetch)
|
|
|
|
response = client.get("/api/disks/snapshot")
|
|
assert response.status_code == 404
|
|
body = response.json()
|
|
assert body["configured"] is False
|
|
assert "HOST_TARGETS" in body["detail"], "the sentence names the fix"
|
|
assert calls == [], "an unconfigured page must not touch the network"
|
|
assert client.app.state.ctx.hub.jobs == {}, (
|
|
"no targets means no disks job, so nothing can age out or go red")
|
|
|
|
|
|
def test_disks_history_serves_the_dev_series_the_page_charts(disks_client) -> None:
|
|
"""One request, every series a host's cards could draw, points from the stored samples."""
|
|
rows = disk_rows(disks_client.get("/api/disks/snapshot").json())
|
|
metrics = [f"{base}@{row['device']}" for row in rows for _field, base in SMART_SERIES]
|
|
body = disks_client.get("/api/disks/history", params={
|
|
"hosts": "driveripper", "metrics": ",".join(metrics), "range": "1h"}).json()
|
|
assert body["hosts"] == ["driveripper"], "one host, one panel"
|
|
assert body["range_seconds"] == 3_600.0
|
|
assert [series["metric"] for series in body["series"]] == metrics
|
|
assert series_source(body) == {"disks"}, "the disks family, never the 10-second host one"
|
|
points = {(series["metric"]): series["points"] for series in body["series"]}
|
|
assert points["disk.temp_c@nvme0n1"][0]["value"] == pytest.approx(38.0)
|
|
assert points["disk.power_on_hours@nvme0n1"][0]["value"] == pytest.approx(8_760.0)
|
|
|
|
|
|
def test_disks_history_answers_empty_for_a_series_a_drive_does_not_publish(
|
|
disks_client) -> None:
|
|
"""Unknown names are an empty series, not a 404 — the /system and /llm contract.
|
|
|
|
The recorded ATA shape reports no `percentage_used`, so that drive's wear plot has
|
|
nothing to draw, and a device name the agent never listed behaves the same way. The card
|
|
asks for every series it could draw and reads `n/a` where there is nothing, instead of
|
|
the request failing.
|
|
"""
|
|
rows = disk_rows(disks_client.get("/api/disks/snapshot").json())
|
|
unworn = next(row["device"] for row in rows
|
|
if (row["smart"] or {}).get("percentage_used") is None)
|
|
body = disks_client.get("/api/disks/history", params={
|
|
"hosts": "driveripper",
|
|
"metrics": f"disk.percentage_used@{unworn},disk.temp_c@nosuchdisk,"
|
|
"disk.temp_c@nvme0n1",
|
|
"range": "1h"}).json()
|
|
by_metric = {series["metric"]: series["points"] for series in body["series"]}
|
|
assert by_metric[f"disk.percentage_used@{unworn}"] == [], "no measurement, no line"
|
|
assert by_metric["disk.temp_c@nosuchdisk"] == []
|
|
assert by_metric["disk.temp_c@nvme0n1"], "the series that does exist still answers"
|
|
|
|
|
|
def test_disks_history_charts_every_configured_host_in_one_request(disks_client) -> None:
|
|
"""With no `hosts` the answer covers every configured agent, dead ones included (empty)."""
|
|
body = disks_client.get("/api/disks/history",
|
|
params={"metrics": "disk.temp_c@nvme0n1,disk.temp_c@sda",
|
|
"range": "24h"}).json()
|
|
assert body["hosts"] == ["driveripper", "deskwork"]
|
|
assert body["range_seconds"] == 86_400.0
|
|
assert {(series["host"], series["metric"]) for series in body["series"]} == {
|
|
("driveripper", "disk.temp_c@nvme0n1"), ("driveripper", "disk.temp_c@sda"),
|
|
("deskwork", "disk.temp_c@nvme0n1"), ("deskwork", "disk.temp_c@sda")}
|
|
assert [series for series in body["series"] if series["host"] == "deskwork"], (
|
|
"a host with no history is an empty series, never a missing one")
|
|
|
|
|
|
def test_the_metrics_endpoint_answers_for_the_disks_family(disks_client) -> None:
|
|
""""Why is this chart empty" has an answer per page: /api/metrics?source=disks."""
|
|
body = disks_client.get("/api/metrics", params={"source": "disks"}).json()
|
|
assert "disk.temp_c@nvme0n1" in body["metrics"]
|
|
assert not [name for name in body["metrics"] if not name.startswith("disk.")], (
|
|
"the disks family holds only disk series")
|
|
assert not [name for name in disks_client.get(
|
|
"/api/metrics", params={"source": "host"}).json()["metrics"]
|
|
if name.startswith("disk.temp_c")], "SMART never mixes into the system family"
|
|
|
|
|
|
# ---- the `gateway` job: wiring, health, alerts, summary (phase 29 task 02) -- #
|
|
#
|
|
# The `gateway` source is a job of its own reading the same agent `/snapshot` the `host:` job reads
|
|
# (the `disks` precedent), so the pins below are about the wiring and the consequences: one job and
|
|
# only for the host `GATEWAY_HOST` names, the one certificate entry shared by all three of that
|
|
# host's jobs, an outage that stays one line on the overview, and a summary block that never turns
|
|
# an unknown into a zero.
|
|
|
|
#: The host name every gateway test here uses — `GATEWAY_HOST`'s own default, so the settings line
|
|
#: under test is "a target whose name matches" and nothing else.
|
|
GATEWAY_TARGET = Target("gateway", "http://127.0.0.1:1")
|
|
|
|
|
|
def hub_of(tmp_path: Path, *, tls: Any | None = None,
|
|
**overrides: Any) -> tuple[Any, SampleStore]:
|
|
"""A hub built exactly as the app builds it, with no event loop and nothing dialled.
|
|
|
|
`build_hub` wires jobs and starts no task, so a shared client that is never used is enough —
|
|
which is also the point: a job that does not exist cannot open a socket. `tls` is passed
|
|
through as the app passes it, because the shared-entry property below is about that argument.
|
|
"""
|
|
db = str(tmp_path / "gateway-hub.sqlite3")
|
|
store = SampleStore(db)
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2, **overrides)
|
|
hub, _monitor = app_module.build_hub(settings, store, httpx.AsyncClient(), tls=tls)
|
|
return hub, store
|
|
|
|
|
|
def test_a_configured_gateway_host_gets_exactly_one_job_of_its_own(
|
|
tmp_path: Path) -> None:
|
|
"""One `gateway` job, on the gateway's clock, labelled with the host it reads.
|
|
|
|
One and not one-per-host: the page is about the single ingress, and a job against every
|
|
machine would put a `gateway` health record on hosts that were never asked to route. Its
|
|
interval is `GATEWAY_INTERVAL_SECONDS`, not the 10 s system one — a connection count is the
|
|
shape of half a minute (invariant 4's isolation, the `disks` shape).
|
|
"""
|
|
hub, store = hub_of(tmp_path,
|
|
host_targets=(Target("deskwork", DEAD_URL),
|
|
Target("router", DEAD_URL),
|
|
Target("driveripper", DEAD_URL)),
|
|
gateway_host="router", gateway_interval=15.0)
|
|
jobs = [job for job in hub.jobs.values() if job.dashboard == "gateway"]
|
|
store.close()
|
|
assert [job.key for job in jobs] == ["gateway"], "the source is one job, not one per host"
|
|
assert jobs[0].label == "router", "the label is the host: an alert and an event print it"
|
|
assert jobs[0].interval == 15.0, "its cadence is GATEWAY_INTERVAL_SECONDS, not the host's"
|
|
assert jobs[0].enabled is True
|
|
assert sorted(hub.jobs) == ["disks:deskwork", "disks:driveripper", "disks:router",
|
|
"gateway", "host:deskwork", "host:driveripper", "host:router"], (
|
|
"the gateway job is a third reader of one agent, not a replacement for either")
|
|
|
|
|
|
def test_a_gateway_name_that_is_not_a_target_gets_no_job_at_all(tmp_path: Path) -> None:
|
|
"""Unconfigured means **no task**, not a task that reports itself red (invariant 3).
|
|
|
|
`GATEWAY_HOST` defaults to `gateway`, and the trap is a deployment that monitors hosts with
|
|
other names: the default must not manufacture a source dialling a machine nobody configured.
|
|
"""
|
|
hub, store = hub_of(tmp_path, host_targets=(Target("deskwork", DEAD_URL),))
|
|
keys = sorted(hub.jobs)
|
|
store.close()
|
|
assert "gateway" not in keys, keys
|
|
assert [key for key in keys if key.startswith("host:")] == ["host:deskwork"]
|
|
|
|
|
|
async def test_the_gateway_job_holds_the_same_credential_as_its_hosts_other_jobs(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""One `AgentTls` per host, handed to all three of its jobs.
|
|
|
|
The dashboard has **one** identity, and the revocation answer is per name — so if the gateway
|
|
job were given a second, separately-built entry it could keep dialling a host the operator just
|
|
struck out (`collectors.AgentTls.status` is a live read, and `agent_tls_map` builds one entry).
|
|
Asserted on object identity, because a structurally-equal second entry would pass a field test
|
|
and still be a second `status` callable to go stale.
|
|
"""
|
|
seen: dict[str, Any] = {}
|
|
entry = app_module.AgentTls(verify="/ca.pem", cert=("/c.pem", "/k.pem"),
|
|
status=lambda: certs.ACTIVE)
|
|
|
|
def record(name: str) -> Any:
|
|
async def scrape(client: Any, target: Target, *_args: Any, **kwargs: Any) -> Any:
|
|
from dashboards.poller import Result # noqa: PLC0415 - the fake's return shape
|
|
seen[f"{name}:{target.name}"] = kwargs.get("tls")
|
|
return Result(payload={}, samples=[])
|
|
return scrape
|
|
|
|
monkeypatch.setattr("dashboards.collectors.fetch_host_snapshot", record("system"))
|
|
monkeypatch.setattr("dashboards.collectors.fetch_disk_snapshot", record("disks"))
|
|
monkeypatch.setattr("dashboards.collectors.fetch_gateway_snapshot", record("gateway"))
|
|
hub, store = hub_of(tmp_path, host_targets=(Target("gateway", "https://gw.test"),
|
|
Target("deskwork", "http://desk.test")),
|
|
tls={"gateway": entry})
|
|
try:
|
|
for job in hub.jobs.values():
|
|
await job.fn()
|
|
finally:
|
|
store.close()
|
|
assert seen == {"system:gateway": entry, "disks:gateway": entry, "gateway:gateway": entry,
|
|
"system:deskwork": None, "disks:deskwork": None}, (
|
|
"the ingress gets its host's entry and no other host gets one")
|
|
|
|
|
|
def test_the_gateway_job_is_handed_no_rate_estimator(tmp_path: Path) -> None:
|
|
"""A count is not a rate, and the wiring is where that could silently break.
|
|
|
|
`build_hub` spans two estimators for the two cadences that measure rates; the `gateway` job
|
|
joins neither, because a connection count pushed through a Δ-window becomes a rate with a
|
|
`Δ<window>` label and no measurement behind it (rule 2). Asserted on the closure the factory
|
|
built: the only arguments it captured are the client, the target, the timeout and the TLS entry
|
|
— no estimator object.
|
|
"""
|
|
hub, store = hub_of(tmp_path, host_targets=(GATEWAY_TARGET,))
|
|
job = hub.jobs["gateway"]
|
|
captured = dict(zip(job.fn.__code__.co_freevars,
|
|
tuple(c.cell_contents for c in job.fn.__closure__ or ()), strict=True))
|
|
store.close()
|
|
assert captured, "the job's collector captured nothing: it cannot be dialling its own target"
|
|
assert not [name for name, value in captured.items()
|
|
if isinstance(value, RateEstimator)], "a count must not be handed a rate estimator"
|
|
assert set(captured) == {"client", "target", "settings", "entry"}, (
|
|
"the gateway fetch takes a client, a target, its settings and one TLS entry — nothing else")
|
|
|
|
|
|
def make_gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
**overrides: Any) -> Any:
|
|
"""An app whose only host is the ingress, on clocks fast enough to poll inside a test.
|
|
|
|
The intervals are the poller's own floor, not 0.05: `Job` clamps its cadence at 0.5 s,
|
|
while the staleness threshold is three times the *configured* interval. At 0.05 s a healthy
|
|
job's gap (≥ 0.5 s) always outlived its own threshold (0.15 s), so its `:stale` row
|
|
appeared in the alert list 70 % of the time and any assertion on the list's length was
|
|
a test of when the event loop happened to tick. At 0.5 s the threshold (1.5 s) is
|
|
three times the gap a healthy job can make, and the premise jobs stay quiet whatever
|
|
the machine is doing."""
|
|
return build_client(tmp_path, monkeypatch, host_targets=(GATEWAY_TARGET,),
|
|
host_interval=0.5, disks_interval=0.5, gateway_interval=0.5,
|
|
**overrides)
|
|
|
|
|
|
def answer_as(client: TestClient, key: str, payload: dict[str, Any]) -> None:
|
|
"""Give one job a collector that hands back `payload`, so its `ok` is truthful.
|
|
|
|
The `healthy_client` idiom: a health record is overwritten only by a poll, so a fixture that
|
|
claims a source is answering swaps the poll's function rather than the record. A failed poll
|
|
keeps the last good payload, which is why a test wanting a *failing* source leaves the dead-port
|
|
`fn` in place and injects the payload as the last good one.
|
|
"""
|
|
from dashboards.poller import Result # noqa: PLC0415 - only these helpers need it
|
|
|
|
async def collect() -> Result:
|
|
return Result(payload=payload, samples=[])
|
|
|
|
client.app.state.ctx.hub.jobs[key].fn = collect # type: ignore[attr-defined]
|
|
|
|
|
|
def gateway_block(client: TestClient) -> dict[str, Any]:
|
|
return client.get("/api/summary").json()["gateway"]
|
|
|
|
|
|
#: The whole `gateway` block, pinned as a **set** rather than as a list of present keys: phase 30
|
|
#: task 02 deleted `unmatched`, `unmatched_listeners` and `partial` with the socket join they
|
|
#: described and task 03 added `warnings` back as a tally, so a block that grew or lost a field
|
|
#: would do it quietly and this fails instead. Phase 33 task 04 added `disabled` as the
|
|
#: thirteenth key: the overview's one line is where "something is switched off" belongs, and a
|
|
#: page-only fact would be invisible to a notifier — it counts the rows reading `enabled: false`,
|
|
#: `0` is the truth (nothing switched off) and `null` is the arm that never read the config.
|
|
#: Three consumers read these names — `page-index.js`'s `GATEWAY_FIELDS` /
|
|
#: `GATEWAY_WARNING_FIELD` / `GATEWAY_DISABLED_FIELD` (resolved against a live document below)
|
|
#: and any notifier walking `/api/summary`.
|
|
GATEWAY_BLOCK_FIELDS = {"configured", "host", "href", "supported", "unreachable",
|
|
"listeners", "connections", "disabled", "live_in", "live_out",
|
|
"map_targets", "warnings", "states"}
|
|
|
|
|
|
def test_the_summary_gateway_block_is_the_stub_of_a_source_that_never_answered(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Configured, dialling, nothing in yet: every count is `null` and the row says unreachable.
|
|
|
|
The `system.hosts` stub rule (invariants 3 and 4): a source in the deployment that has not
|
|
answered is present with unknowns, because dropping it would read as "never configured" and
|
|
zeroing it would read as an idle gateway. The poll has to have happened for the stub to mean
|
|
what it says, so the test waits for the dead port's sentence first.
|
|
|
|
Note which fields are null **here**: the two row counts included. A host that has never
|
|
replied has not told us what its configs declare either, and `0` would be the sentence "this
|
|
gateway routes nothing" said about a machine that never said it.
|
|
"""
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
wait_for_source_error(client, "gateway", "unreachable")
|
|
block = gateway_block(client)
|
|
assert block["configured"] is True
|
|
assert block["host"] == "gateway"
|
|
assert block["unreachable"] is True
|
|
assert block["supported"] is None, "nobody has answered, so nobody knows the version"
|
|
assert set(block) == GATEWAY_BLOCK_FIELDS
|
|
for field in ("listeners", "connections", "disabled", "live_in", "live_out",
|
|
"map_targets", "warnings"):
|
|
assert block[field] is None, f"{field} must be unknown, never 0"
|
|
assert [row["key"] for row in block["states"]] == ["gateway"]
|
|
assert block["states"][0]["dashboard"] == "gateway"
|
|
# the link travels even here: the overview renders it, and a block that grew `href` only
|
|
# when configured would make the page test for two shapes of the same row
|
|
assert block["href"] == GATEWAY_HREF
|
|
|
|
|
|
def endpoint_identity(out: dict[str, Any]) -> tuple[Any, ...] | None:
|
|
"""One endpoint, in the currency the kernel writes — derived here, not by the source.
|
|
|
|
An endpoint written as an address **is** that address; an endpoint written as a name is every
|
|
address the agent's resolver published for it (`out.resolved`), and `ipaddress` folds the
|
|
`::ffff:`-mapped and zero-padded spellings so one address in two tables is one key. `None`
|
|
means the join has nothing to name the far end with, which contributes nothing to a total.
|
|
|
|
This is deliberately a **third** reading of "what is one endpoint" (the others are
|
|
`gateway.endpoint_of` behind `api._endpoint_values`, and the join itself): since phase 32 two
|
|
route lines can reach one address while the configs spell it as two different names, and a
|
|
total keyed on the config's text would book it once per name.
|
|
"""
|
|
port = out.get("port")
|
|
if not isinstance(port, int):
|
|
return None
|
|
spellings = ([out["host"]] if out.get("address") is True
|
|
else list(out.get("resolved") or []))
|
|
keys: list[str] = []
|
|
for spelling in spellings:
|
|
try:
|
|
address = ipaddress.ip_address(str(spelling))
|
|
except ValueError:
|
|
continue
|
|
mapped = getattr(address, "ipv4_mapped", None)
|
|
keys.append(str(mapped if mapped is not None else address))
|
|
return (min(keys), port) if keys else None
|
|
|
|
|
|
def endpoint_sums(payload: dict[str, Any]) -> tuple[int | None, int | None]:
|
|
"""The two live totals, derived here so the block is checked against a **second** reading.
|
|
|
|
Inbound is the sum over trunks. Outbound is the sum over the **distinct endpoints** the route
|
|
lines reach: `sources/gateway.py` repeats an endpoint's whole count on every line that reaches
|
|
it (`shared: true`), so summing the lines would book the same sockets twice and print a router
|
|
busier than the kernel has connections for. Neither is a sum when nothing answered.
|
|
"""
|
|
live_in = [row["connections"] for row in payload["listeners"]
|
|
if isinstance(row["connections"], int)]
|
|
endpoints: dict[tuple[Any, ...], Any] = {}
|
|
for row in payload["connections"]:
|
|
key = endpoint_identity(row["out"])
|
|
if key is not None:
|
|
endpoints.setdefault(key, row["connections"])
|
|
live_out = [value for value in endpoints.values() if isinstance(value, int)]
|
|
return (sum(live_in) if live_in else None,
|
|
sum(live_out) if live_out else None)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["full", "resolved", "predates", "sockets_silent",
|
|
"marked", "hand_edited"])
|
|
def test_the_summary_gateway_block_is_the_payloads_sums_not_the_pages_arithmetic(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any,
|
|
mode: str) -> None:
|
|
"""The block re-derives nothing: each figure is the projection's own, in all three arms.
|
|
|
|
Driven from `gateway.parse` over the **captured** configs and `/proc` tables rather than a
|
|
hand-written payload, so a rename in the source's shape fails here instead of printing a
|
|
plausible zero (rule 7).
|
|
|
|
The split that replaced `partial` is the point of the arms. `listeners` / `connections`
|
|
are **row counts** — a fact readable from a config file, so they are numbers even when the
|
|
socket table never answered — while `live_in` / `live_out` are measurements and go `null` with
|
|
it. The old block could only report one number per side and needed a `partial` flag to say it
|
|
was a subtotal; there is no residual to qualify now, because a socket outside the two files is
|
|
counted nowhere by decision.
|
|
|
|
The `resolved` arm is phase 32's, and it is here because the **block** must not change when the
|
|
join widens: twelve fields, the same names, and `live_out` still the sum over distinct
|
|
endpoints — now endpoints reached through a resolved name as well as an address, each booked
|
|
once however many lines reach it. A block that summed the lines instead would move from 15 to a
|
|
bigger, wronger number on the same capture.
|
|
"""
|
|
payload = gateway_source.parse(gateway_snapshot(mode), "gateway")
|
|
expected_in, expected_out = endpoint_sums(payload)
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
answer_as(client, "gateway", payload)
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
|
time.sleep(0.05)
|
|
block = gateway_block(client)
|
|
|
|
assert block["configured"] is True and block["unreachable"] is False
|
|
assert block["supported"] is payload["supported"]
|
|
assert block["host"] == "gateway"
|
|
assert set(block) == GATEWAY_BLOCK_FIELDS, mode
|
|
if not payload["supported"]:
|
|
# nothing was read, so even "this host declares no listener" would be a claim about a
|
|
# file nobody opened: `supported: false` and nulls are the honest pair
|
|
assert block["map_targets"] is None
|
|
assert block["listeners"] is block["connections"] is None
|
|
assert block["live_in"] is block["live_out"] is None
|
|
assert block["disabled"] is None, "a config nobody read has no off row to count"
|
|
assert block["warnings"] is None, "an unread config has not been found guilty of anything"
|
|
return
|
|
assert block["map_targets"] == len(payload["nginx"]["map_targets"])
|
|
assert block["listeners"] == len(payload["listeners"])
|
|
assert block["connections"] == len(payload["connections"])
|
|
# the off-row tally: a row the config wrote off, counted from the rows, not re-read.
|
|
# `marked` is three markers, so `3` is the pin the phase's criterion asks for; `full`'s
|
|
# `0` is the clean capture's truth, and `hand_edited`'s `0` says removing the markers
|
|
# removes the off state (the shape without the marker is on, and it warns)
|
|
expected_disabled = sum(1 for row in payload["connections"] if row.get("enabled") is False)
|
|
assert block["disabled"] == expected_disabled, mode
|
|
if mode == "marked":
|
|
assert block["disabled"] == 3
|
|
if mode == "full":
|
|
assert block["disabled"] == 0
|
|
# a **count**, not the list: `/` needs to know whether there is anything to address, and the
|
|
# page that lists them reads `/api/gateway/snapshot`
|
|
assert block["warnings"] == len(payload["warnings"])
|
|
assert block["live_in"] == expected_in
|
|
assert block["live_out"] == expected_out
|
|
if payload["sockets"]["established"] is None:
|
|
# the configs answered and the kernel did not: the rows are real, the counts are not
|
|
assert block["listeners"] > 0 and block["connections"] > 0
|
|
assert block["live_in"] is block["live_out"] is None, "unknown is not zero"
|
|
else:
|
|
assert block["live_in"] is not None
|
|
assert block["live_in"] + (block["live_out"] or 0) <= payload["sockets"]["established"], (
|
|
"the reported figures may under-count the kernel's, never exceed it")
|
|
|
|
|
|
def test_the_summary_gateway_block_of_the_captured_ingress_reads_the_live_join(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The real capture, one figure at a time — the block an operator would actually get.
|
|
|
|
The parametrised test above proves the rule; this one pins the numbers, so a change to the join
|
|
that moves a total is visible somewhere rather than only in a browser. The three config-shaped
|
|
figures are literal, because only the operator moves them; the two live totals are derived from
|
|
the capture rather than quoted, because a re-capture of a busy router lawfully moves them.
|
|
|
|
Since phase 32 task 03 the outbound total is no longer pinned to the two `127.0.0.1` endpoints:
|
|
the recorded `gateway_resolver.json` places every name this ingress routes to, so the routes
|
|
that used to be dashes are counted through the address the gateway dialled. What is still pinned
|
|
is the **rule** the number comes from — one count per distinct endpoint, and never more than the
|
|
kernel has connections for.
|
|
"""
|
|
payload = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
answer_as(client, "gateway", payload)
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
|
time.sleep(0.05)
|
|
block = gateway_block(client)
|
|
assert block["listeners"] == 10, "6 haproxy binds + 4 nginx listen ports"
|
|
assert block["connections"] == 33, "every route the two captured configs declare"
|
|
assert block["map_targets"] == 18, "the stream map's routing entries are the fan-out"
|
|
# The two live totals are the volatile half: a capture of a running router is a different set of
|
|
# connections every second, so they are compared with the payload the block was built from
|
|
# rather than with a number typed here (the doctrine this suite already keeps for the socket
|
|
# fixtures themselves — assert the shape and the rule, never a particular row). What stays
|
|
# literal is what only the two config files decide, above, and what stays asserted as a
|
|
# relationship is the direction of the error, below.
|
|
assert block["live_in"] == sum(row["connections"] or 0 for row in payload["listeners"])
|
|
_, expected_out = endpoint_sums(payload)
|
|
assert block["live_out"] == expected_out, "one count per distinct endpoint, never one per line"
|
|
assert sum(row["connections"] or 0 for row in payload["connections"]) >= (expected_out or 0), (
|
|
"the lines repeat a shared endpoint's count, which is exactly why the block adds the "
|
|
"endpoints up instead")
|
|
assert block["live_in"] + block["live_out"] <= payload["sockets"]["established"], (
|
|
"the reported figures may under-count the kernel's, never exceed it")
|
|
assert block["warnings"] == 0, (
|
|
"this fleet's two config files connect to themselves, and 0 is the truth rather than a "
|
|
"missing measurement — the overview line prints nothing for it either way")
|
|
assert "severity" not in block
|
|
assert block["href"] == GATEWAY_HREF
|
|
|
|
|
|
def gateway_alerts(client: Any) -> list[dict[str, Any]]:
|
|
"""`/api/summary`'s alert rows for an app whose jobs are already in the state we asked for."""
|
|
return client.get("/api/summary").json()["alerts"]
|
|
|
|
|
|
def test_one_dangling_use_backend_is_one_warn_row_on_the_overview(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Both directions of the row, over the **real** projection of the captured configs.
|
|
|
|
The `self_test_app` device (swap the collector so a poll cannot overwrite what the test set):
|
|
the mutation is a `use_backend` naming a backend nobody declares, applied to the haproxy section
|
|
the agent's own parser produced, so the warning, its message and its count are the source's and
|
|
not this file's. Then the same document with the mutation removed, because an absence-only
|
|
assertion is satisfied by deleting the feature.
|
|
"""
|
|
clean = gateway_source.parse(gateway_snapshot("full"), "gateway")
|
|
broken_snapshot = gateway_snapshot("full")
|
|
broken_snapshot["haproxy"]["frontends"][0]["backends"] = ["old-tunnel"]
|
|
broken = gateway_source.parse(broken_snapshot, "gateway")
|
|
assert [row["kind"] for row in clean["warnings"]] == []
|
|
assert [row["kind"] for row in broken["warnings"]] == [gateway_source.WARN_ROUTE_DANGLING]
|
|
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
answer_as(client, "host:gateway", {"name": "gateway"})
|
|
answer_as(client, "disks:gateway", {"name": "gateway", "disks": [], "summary": {}})
|
|
answer_as(client, "gateway", broken)
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
|
time.sleep(0.05)
|
|
# The two host jobs are this test's premise, not its subject: on a slow machine the wait
|
|
# loops above outlive their 1.5 s staleness window, and the alert then gains a `:stale`
|
|
# row for the premise and the assertion below reads a three-row list. Stamping their
|
|
# `last_ok` pins the premise the docstring states ("the agent answers") and leaves the
|
|
# gateway row the only line.
|
|
for host_key in ("host:gateway", "disks:gateway"):
|
|
client.app.state.ctx.hub.jobs[host_key].state.last_ok = time.time()
|
|
rows = gateway_alerts(client)
|
|
block = client.get("/api/summary").json()["gateway"]
|
|
assert len(rows) == 1, rows
|
|
assert rows[0]["id"] == "gateway:config"
|
|
assert rows[0]["severity"] == "warn"
|
|
assert rows[0]["source"] == "gateway"
|
|
assert rows[0]["href"] == "/gateway"
|
|
assert rows[0]["message"] == (
|
|
'1 proxy config problem to address — frontend haproxy-22 routes to backend '
|
|
'"old-tunnel", which no backend block declares')
|
|
assert block["warnings"] == 1, "the tally and the row are the same reading of one payload"
|
|
|
|
# …and removing the mutation removes the row: the alert is the config, not the test rig
|
|
answer_as(client, "gateway", clean)
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline and client.get("/api/summary").json()["gateway"]["warnings"]:
|
|
time.sleep(0.05)
|
|
for host_key in ("host:gateway", "disks:gateway"):
|
|
client.app.state.ctx.hub.jobs[host_key].state.last_ok = time.time()
|
|
assert gateway_alerts(client) == [], "a clean ingress is an all clear, not a green line"
|
|
|
|
|
|
def test_a_gateway_job_that_stops_answering_keeps_one_row_not_two(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The last good payload still holds its warnings; the overview still says one thing.
|
|
|
|
Invariant 4's page side: a failed poll keeps the payload, and `_source_alerts` writes the
|
|
reachability row for the job. Task 03's rule is that the config row goes quiet then — the
|
|
operator's problem right now is that the router is not answering, and a second amber line
|
|
quoting a reading they cannot refresh is noise on top of it.
|
|
"""
|
|
broken = gateway_source.parse(gateway_snapshot("full"), "gateway")
|
|
broken["warnings"] = [{"id": "haproxy_backend_orphan:old-tunnel",
|
|
"kind": "haproxy_backend_orphan", "subject": "old-tunnel",
|
|
"message": 'backend "old-tunnel" has 2 servers and no frontend '
|
|
"routes to it"}]
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
# the agent answers; only the proxy reading inside it goes quiet, which is the arm this
|
|
# rule is about — a dead agent is `host:gateway`'s sentence and echoes to nothing
|
|
answer_as(client, "host:gateway", {"name": "gateway"})
|
|
answer_as(client, "disks:gateway", {"name": "gateway", "disks": [], "summary": {}})
|
|
answer_as(client, "gateway", broken)
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
|
time.sleep(0.05)
|
|
assert client.get("/api/summary").json()["gateway"]["warnings"] == 1, (
|
|
"the control: answering, the config row IS there")
|
|
|
|
async def failing() -> Any:
|
|
raise UpstreamError("gateway: the proxy sections went quiet")
|
|
|
|
client.app.state.ctx.hub.jobs["gateway"].fn = failing # type: ignore[attr-defined]
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
job = client.app.state.ctx.hub.jobs["gateway"]
|
|
if not job.state.ok and job.state.payload is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
else:
|
|
pytest.fail("the gateway job never went from answering to failing")
|
|
# The two host jobs are this test's premise, not its subject: on a slow machine the wait
|
|
# loops above outlive their 1.5 s staleness window, and the alert then gains a `:stale`
|
|
# row for the premise and the assertion below reads a three-row list. Stamping their
|
|
# `last_ok` pins the premise the docstring states ("the agent answers") and leaves the
|
|
# gateway row the only line.
|
|
for host_key in ("host:gateway", "disks:gateway"):
|
|
client.app.state.ctx.hub.jobs[host_key].state.last_ok = time.time()
|
|
alerts = gateway_alerts(client)
|
|
assert [row["id"] for row in alerts] == ["gateway:gateway:down"], (
|
|
"the reachability row is the gateway's only line while it is down")
|
|
assert client.get("/api/gateway/snapshot").json()["warnings"], (
|
|
"the page still shows the warnings beside the last good numbers — the alert went quiet, "
|
|
"the fact did not disappear")
|
|
|
|
|
|
#: The route the ingress's own line links to, taken from the one table that writes a dashboard's
|
|
#: page (`alerts.py`) rather than from a string this file would have to keep in sync with it.
|
|
GATEWAY_HREF = alerts.HREF["gateway"]
|
|
|
|
|
|
def test_the_gateway_block_links_to_the_page_the_alert_row_links_to() -> None:
|
|
"""One table says where a dashboard lives, and the summary block reads it out of that table.
|
|
|
|
`/api/summary`'s `gateway.href` is what turns the overview's line into a link, and an alert
|
|
about the same source carries an `href` too (`test_a_gateway_job_that_fails_while_its_host_…`).
|
|
Two literals would be two chances to disagree, so `api._gateway_summary` reads
|
|
`alerts.HREF[GATEWAY_DASHBOARD]`, and this pins both halves: the value is a path of this app by
|
|
`auth.safe_next`'s own rule, and the derivation is still the table rather than a fresh string.
|
|
"""
|
|
assert GATEWAY_HREF == "/gateway"
|
|
assert auth.safe_next(GATEWAY_HREF) == GATEWAY_HREF, "the block's href is not a path of this app"
|
|
assert api._gateway_summary(None, Settings())["href"] == GATEWAY_HREF
|
|
assert "alert_rules.HREF[GATEWAY_DASHBOARD]" in inspect.getsource(api._gateway_summary), (
|
|
"`_gateway_summary` stopped deriving `href` from `alerts.HREF` — the overview's link and "
|
|
"an alert about the same source can now spell the page differently")
|
|
|
|
|
|
# ---- alerts: one dead agent is one line, and a count is never a verdict ----- #
|
|
|
|
def test_a_dead_gateway_host_is_one_overview_line_not_three(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Three jobs read one agent; a dead router is one outage (`AGENTS.md`, one line per fault).
|
|
|
|
Without the echo rule the operator would get `gateway` three times on `/` — the host, its
|
|
drives, and its pipes — for the one fact that the machine is not answering. The `system` row
|
|
is the voice that stays, because it names the machine and links to the page that says most.
|
|
"""
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
rows = {row["key"]: row for row in client.get("/api/health").json()["sources"]}
|
|
if rows and all(not row["ok"] for row in rows.values()) and len(rows) == 3:
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
pytest.fail("the three jobs of one dead agent never all recorded a failure")
|
|
alerts = client.get("/api/summary").json()["alerts"]
|
|
assert len(alerts) == 1, alerts
|
|
assert alerts[0]["source"] == "gateway"
|
|
assert alerts[0]["href"] == "/system", "the outage is the host's, and /system is its page"
|
|
|
|
|
|
def test_a_gateway_job_that_fails_while_its_host_answers_is_the_only_voice_it_has(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The other arm: the agent answers and its proxy/socket half does not.
|
|
|
|
Nothing else can say this — `/system` renders a healthy host, so a gateway job silenced by the
|
|
echo rule whenever its host is down would also be silenced here, and the fault would vanish.
|
|
The line links to `/gateway`, the page that explains which section went quiet.
|
|
"""
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
answer_as(client, "host:gateway", {"name": "gateway"})
|
|
answer_as(client, "disks:gateway", {"name": "gateway", "disks": [], "summary": {}})
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
rows = {row["key"]: row for row in client.get("/api/health").json()["sources"]}
|
|
if (rows.get("host:gateway") or {}).get("ok") and not (rows.get("gateway") or {}).get(
|
|
"ok", True):
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
pytest.fail("the gateway job never failed while its host answered")
|
|
alerts = client.get("/api/summary").json()["alerts"]
|
|
assert [item["source"] for item in alerts] == ["gateway"]
|
|
assert alerts[0]["href"] == "/gateway"
|
|
assert "unreachable" in alerts[0]["message"]
|
|
|
|
|
|
#: The four figures the overview's ingress line prints, in the order it prints them. Read back out
|
|
#: of the served `GATEWAY_FIELDS` and resolved against the block the route answers with, so a
|
|
#: rename on either side fails here instead of printing `n/a` beside a busy gateway. The first two
|
|
#: are the config's own declarations and the last two what the kernel's socket table answered — the
|
|
#: same split `api._gateway_summary` writes, and the reason one number per side could never say both
|
|
#: things. The words are the **field names** (phase 30 task 02): the line, the block and the page
|
|
#: then say one thing about a route, and a rename on either side fails in three places at once.
|
|
GATEWAY_LINE_FIELDS = ("listeners", "connections", "live_in", "live_out")
|
|
GATEWAY_LINE_WORDS = ("listeners", "connections", "live in", "live out")
|
|
#: The tally beside them, which is NOT in `GATEWAY_FIELDS` because it prints only when non-zero:
|
|
#: the four figures above always have a place on the line, and `N to address` has one only when
|
|
#: there is something to address (phase 30 task 03).
|
|
GATEWAY_LINE_WARNING = ("warnings", "to address")
|
|
#: The off-row tally beside it, in the same position the `warnings` tally keeps: NOT in
|
|
#: `GATEWAY_FIELDS`, because it prints only when non-zero — a clean ingress must not grow a
|
|
#: permanent `0 switched off` (the empty-state rules exist to avoid that ink), and a `null`
|
|
#: (an ingress that never answered) is not a number to print at all (phase 33 task 04).
|
|
GATEWAY_LINE_DISABLED = ("disabled", "switched off")
|
|
|
|
|
|
def test_the_overview_line_reads_the_gateway_block_it_claims(client) -> None:
|
|
"""The sixth dashboard's one line on `/` indexes the summary block and nothing else.
|
|
|
|
The `SLOT_FIELDS` device on the row phase 29 task 05 added: the served script's field table is
|
|
parsed and every name in it is resolved against the live `/api/summary` document, so a key that
|
|
quietly changes spelling fails a test rather than rendering `n/a` next to an ingress carrying
|
|
traffic. Three more claims ride on the same slice, because each is a rule the row could break
|
|
silently: the link is the block's own `href` (**no route literal in the renderer**, the rule the
|
|
alert rows keep), the row hides itself when `configured` is false instead of printing a red
|
|
nothing (invariant 3), and it carries no `severity` — a connection count is not a graded number
|
|
(`net.*` precedent), so a colour here would be a threshold invented in JavaScript.
|
|
"""
|
|
js = _code(client.get("/static/page-index.js").text)
|
|
table = re.search(r"const GATEWAY_FIELDS = \[(.*?)\n\];", js, re.S)
|
|
assert table, "page-index.js no longer has a GATEWAY_FIELDS table"
|
|
keys = re.findall(r"key: '([\w]+)'", table.group(1))
|
|
words = re.findall(r"word: '([^']+)'", table.group(1))
|
|
assert keys == list(GATEWAY_LINE_FIELDS), f"the line prints {keys}, not the pinned four"
|
|
assert words == list(GATEWAY_LINE_WORDS), f"the line labels them {words}"
|
|
|
|
body = client.get("/api/summary").json()
|
|
assert "gateway" in body, "/api/summary lost the block the line reads"
|
|
disabled_table = re.search(r"const GATEWAY_DISABLED_FIELD = (\{.*?\});", js, re.S)
|
|
assert disabled_table, "page-index.js no longer names the off-row field in a table"
|
|
disabled_name = re.search(r"key: '([\w]+)'", disabled_table.group(1))
|
|
assert disabled_name, "the off-row table lost its key"
|
|
for key in (*keys, "warnings", disabled_name.group(1), "configured", "href", "host",
|
|
"unreachable"):
|
|
assert key in body["gateway"], f"the page names `{key}` and the block does not send it"
|
|
assert not ({"partial", "unmatched", "unmatched_listeners"} & set(body["gateway"])), (
|
|
"the block grew back a field the deleted join needed")
|
|
|
|
line = js[js.index("function renderGateway("):]
|
|
line = line[:line.index("\n}")]
|
|
assert "block.href" in line, "the row stopped linking with the block's own href"
|
|
for literal in ("'/gateway'", '"/gateway"'):
|
|
assert literal not in line, f"the renderer hard-codes {literal} instead of reading `href`"
|
|
assert "if (block.configured !== true) {" in line and "row.hidden = true;" in line, (
|
|
"an unconfigured ingress no longer renders no line — the disks block's invariant-3 rule")
|
|
assert "row.classList.toggle('stale'" in line, (
|
|
"a gateway that stopped answering keeps its last counts, so the row must say they are old")
|
|
assert "severity" not in line, "the ingress line grew a verdict the block does not send"
|
|
# every figure goes through `num()`, which is what renders a `null` as `n/a` rather than `0`
|
|
assert "num(block[key], 0)" in line, (
|
|
"a live count the block did not measure must print n/a, not 0 (rule 2)")
|
|
assert "escapeHtml" in line, "a config string reached innerHTML unescaped"
|
|
|
|
# the config-integrity tally: read out of a named table (so a rename fails here, not quietly),
|
|
# and printed ONLY when it is non-zero — a clean ingress must not grow a permanent `0 to
|
|
# address`, and a `null` (an ingress that never answered) is not a number to print at all.
|
|
warning_table = re.search(r"const GATEWAY_WARNING_FIELD = (\{.*?\});", js, re.S)
|
|
assert warning_table, "page-index.js no longer names the warnings field in a table"
|
|
warn_key = re.search(r"key: '([\w]+)'", warning_table.group(1))
|
|
warn_word = re.search(r"word: '([^']+)'", warning_table.group(1))
|
|
assert warn_key and warn_word, "the tally table lost its key or its word"
|
|
assert (warn_key.group(1), warn_word.group(1)) == GATEWAY_LINE_WARNING, (
|
|
f"the line reads {warn_key.group(1)} / {warn_word.group(1)}, not the pinned tally")
|
|
assert "block[GATEWAY_WARNING_FIELD.key]" in line, (
|
|
"the renderer stopped reading the tally through its table")
|
|
assert ("` · ${block[GATEWAY_WARNING_FIELD.key]} ${GATEWAY_WARNING_FIELD.word}`" in line)
|
|
assert "> 0" in line, "the tally prints even when it is zero — a clean ingress gets no row"
|
|
assert "Number.isInteger" in line, (
|
|
"a null tally must not print: `null > 0` is false, and that is the unknown arm")
|
|
|
|
# the off-row tally: the same rule restated for the switch — a named table (so a rename
|
|
# fails here rather than printing a stale name), read through it, and the clause is guarded
|
|
# by the same `> 0` / `Number.isInteger` pair: `0` prints nothing, `null` prints nothing.
|
|
disabled_word = re.search(r"word: '([^']+)'", disabled_table.group(1))
|
|
assert disabled_word, "the off-row table lost its word"
|
|
assert (disabled_name.group(1), disabled_word.group(1)) == GATEWAY_LINE_DISABLED, (
|
|
f"the line reads {disabled_name.group(1)} / {disabled_word.group(1)}, not the pinned tally")
|
|
assert "block[GATEWAY_DISABLED_FIELD.key]" in line, (
|
|
"the renderer stopped reading the off-row tally through its table")
|
|
assert "` · ${block[GATEWAY_DISABLED_FIELD.key]} ${GATEWAY_DISABLED_FIELD.word}`" in line, (
|
|
"the off-row clause left the line")
|
|
assert line.count("> 0") >= 2, "one of the two non-zero tally clauses lost its guard"
|
|
assert line.count("Number.isInteger") >= 2, "a null tally must not print, for either tally"
|
|
|
|
# …and the element it fills is in the fleet panel and ships hidden, so the row cannot appear
|
|
# before the payload says the source exists.
|
|
html = client.get("/").text
|
|
assert re.search(r'<p[^>]*id="fleet-gate"[^>]*hidden>', html), (
|
|
"`/` has no hidden #fleet-gate for renderGateway() to reveal")
|
|
assert html.count("<section") == 2, "the ingress line became a third overview panel"
|
|
refresh = js[js.index("async function refresh("):]
|
|
assert "renderGateway(summary.gateway)" in refresh, "the line is not fed by the page's own fetch"
|
|
|
|
|
|
def test_the_gateway_counts_grow_no_verdict_alert_even_with_verdicts_in_the_payload(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The rule the phase wrote down as an assertion: `alerts.py` has no `gateway` threshold.
|
|
|
|
Deliberately fed a payload with `severity` dicts stamped into every level of it — if a future
|
|
rule ever *read* a gateway verdict it would light up the overview over a busy proxy, which is
|
|
the job on a gateway and not a fault (`severity.py` rates no connection count, the `net.*`
|
|
precedent). An answering fleet with a fully-populated gateway payload stays all clear.
|
|
"""
|
|
payload = gateway_source.parse(gateway_snapshot("full"), "gateway")
|
|
payload["severity"] = {"listeners": "fail", "connections": "fail", "sockets": "fail"}
|
|
for row in (*payload["listeners"], *payload["connections"]):
|
|
row["severity"] = "fail"
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
answer_as(client, "host:gateway", {"name": "gateway"})
|
|
answer_as(client, "disks:gateway", {"name": "gateway", "disks": [], "summary": {}})
|
|
answer_as(client, "gateway", payload)
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
rows = {row["key"]: row for row in client.get("/api/health").json()["sources"]}
|
|
if rows and all(row["ok"] for row in rows.values()):
|
|
break
|
|
time.sleep(0.1)
|
|
else:
|
|
pytest.fail("the three jobs never all reported ok")
|
|
body = client.get("/api/summary").json()
|
|
assert body["alerts"] == [], "a connection count is not a verdict from any direction"
|
|
assert "severity" not in body["gateway"]
|
|
|
|
|
|
def test_a_revoked_gateway_agent_fails_all_three_of_its_jobs_without_a_dial(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`/certs` says revoke; the pipes, the drives and the host all stop talking to it.
|
|
|
|
The phase-15 pin extended to the third job. What must not happen is a *socket*: the refusal
|
|
lives in `collectors.fetch_snapshot`, in front of the network, and it is reached through the
|
|
same `AgentTls` entry the host's other two jobs hold (`agent_tls_map`, built once at startup
|
|
with a live revocation read) — which is the only reason one click covers three sources.
|
|
"""
|
|
db = str(tmp_path / "revoked-gateway.sqlite3")
|
|
store = SampleStore(db)
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_interval=0.05, disks_interval=0.05, gateway_interval=0.05,
|
|
host_targets=(Target(name="gateway", url="https://127.0.0.1:1"),))
|
|
with TestClient(create_app(settings, store)) as client:
|
|
sign_in(client)
|
|
# the control: this is what a dial of a dead host says, so the sentence after it means
|
|
# something else than "the socket failed"
|
|
wait_for_source_error(client, "host:gateway", "unreachable")
|
|
state = certs.issue_agent_cert(store, certs.ensure_certs(store), "gateway",
|
|
["127.0.0.1"])
|
|
certs.revoke(store, state, "gateway")
|
|
|
|
calls: list[str] = []
|
|
|
|
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
calls.append(str(url))
|
|
raise AssertionError(f"a revoked agent was dialled at {url}")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
|
|
for key in ("host:gateway", "disks:gateway", "gateway"):
|
|
error = wait_for_source_error(client, key, "agent certificate revoked")
|
|
assert "unreachable" not in error
|
|
assert calls == [], "the gate is in front of the network, not behind it"
|
|
messages = [row["message"] for row in client.get("/api/summary").json()["alerts"]]
|
|
assert sum(1 for text in messages if "certificate revoked" in text) == 1, (
|
|
"one revoked agent is one line, whatever number of jobs read it")
|
|
store.close()
|
|
|
|
|
|
# ---- the `/api/gateway/*` routes (phase 29 task 03) ------------------------ #
|
|
#
|
|
# The page's two data routes, in the shape the `/disks` pair established: the configured gate runs
|
|
# before anything that could reach the network (both arms spy-pinned), an answering payload keeps
|
|
# the projection's exact keys, a source that has never answered is the `unreachable` stub of
|
|
# `null`s, and the history route lands on the store's one batched door and answers the points a
|
|
# direct `query()` read returns. A fifth data page grows no write of its own (invariant 1), which
|
|
# is what the verb enumeration below is for.
|
|
|
|
# Every key `/gateway` indexes, in the arm that measured. Read off a live `parse` rather than
|
|
# hand-written, so a rename in `sources/gateway.py` fails the route pins instead of turning a
|
|
# card into `n/a` beside a healthy ingress.
|
|
def gateway_payload_keys(snapshot: Any) -> set[str]:
|
|
return set(gateway_source.parse(snapshot, "gateway"))
|
|
|
|
|
|
def find_keys(node: Any, name: str) -> list[Any]:
|
|
"""Every value named `name` anywhere in a payload — the no-verdict audit."""
|
|
if isinstance(node, Mapping):
|
|
found = [value for key, value in node.items() if key == name]
|
|
for value in node.values():
|
|
found.extend(find_keys(value, name))
|
|
return found
|
|
if isinstance(node, list):
|
|
return [hit for item in node for hit in find_keys(item, name)]
|
|
return []
|
|
|
|
|
|
def _gateway_routes_client(tmp_path: Path, mode: str,
|
|
gateway_snapshot: Any) -> Iterator[TestClient]:
|
|
"""One app per capture mode, its gauges already stored, its payload already in the job state.
|
|
|
|
Both halves come from `sources/gateway` over the **captured** tree, so every path a page reads
|
|
and every series name a chart asks for is one the source really emits (rule 7). The target
|
|
points at a dead port: no poll can overwrite the payload, dial out, or add a second sample
|
|
between the store read and the route assertion below.
|
|
"""
|
|
db = str(tmp_path / f"gateway-routes-{mode}.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(GATEWAY_TARGET,)), store)
|
|
with TestClient(app) as test_client:
|
|
sign_in(test_client)
|
|
now = time.time()
|
|
snapshot = gateway_snapshot(mode)
|
|
snapshot["ts"] = now # "the agent answered just now", so charts are in range
|
|
result = gateway_source.parse_gateway_snapshot("gateway", snapshot, now=now)
|
|
store.add_many(result.samples)
|
|
app.state.ctx.hub.jobs["gateway"].state.payload = result.payload
|
|
yield test_client
|
|
store.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
gateway_snapshot: Any) -> Iterator[TestClient]:
|
|
"""The ingress answering as agent **1.2.0** (or 1.3.0 with `--no-resolve`) does.
|
|
|
|
No `resolver` section, so the routes whose targets the two config files write as domains carry
|
|
no count, no gauge and no stored series — phase 30 verbatim, which is what the phase-32 pins
|
|
below compare against.
|
|
"""
|
|
yield from _gateway_routes_client(tmp_path, "full", gateway_snapshot)
|
|
|
|
|
|
@pytest.fixture
|
|
def resolved_gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
gateway_snapshot: Any) -> Iterator[TestClient]:
|
|
"""The same ingress answering as agent **1.3.0**: the captured resolver section attached.
|
|
|
|
The document is the recorded `gateway_resolver.json`, so every series name this fixture's store
|
|
holds is one the deployed ingress will really write after task 05 — including the **name-keyed**
|
|
outbound gauges phase 30 stopped writing, which are writable again precisely because the
|
|
gateway published the address behind the name.
|
|
"""
|
|
yield from _gateway_routes_client(tmp_path, "resolved", gateway_snapshot)
|
|
|
|
|
|
def dial_spies(monkeypatch: pytest.MonkeyPatch, calls: list[str]) -> None:
|
|
"""Two spies that raise: a route that dials before its gate fails here, not in a timeout.
|
|
|
|
The `disks` pair's instrument, named once because three routes are now pinned with it (the
|
|
snapshot's two unconfigured arms and the history route's) and a copy per test is how one of
|
|
them stops raising.
|
|
"""
|
|
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
calls.append(f"GET {url}")
|
|
raise AssertionError(f"the unconfigured gateway route dialled {url}")
|
|
|
|
def spy_fetch(*args: Any, **kwargs: Any) -> Any:
|
|
calls.append("fetch_snapshot")
|
|
raise AssertionError("the unconfigured gateway route fetched an agent snapshot")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
|
|
monkeypatch.setattr("dashboards.collectors.fetch_snapshot", spy_fetch)
|
|
|
|
|
|
def test_gateway_snapshot_404s_when_no_host_is_named_the_gateway(
|
|
client, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`GATEWAY_HOST` unset and no targets: `404 {"configured": false}`, nothing dialled.
|
|
|
|
Invariant 3, and the arm a deployment without an ingress lives in. The spies raise, so a route
|
|
that built a client or fetched a snapshot before testing `gateway_enabled` fails here instead
|
|
of costing a connect timeout and a red dot, and the empty hub is the structural half: with no
|
|
targets there is no `gateway` job that could age out.
|
|
"""
|
|
calls: list[str] = []
|
|
dial_spies(monkeypatch, calls)
|
|
|
|
response = client.get("/api/gateway/snapshot")
|
|
assert response.status_code == 404
|
|
body = response.json()
|
|
assert body["configured"] is False
|
|
assert "GATEWAY_HOST" in body["detail"], "the sentence names the fix"
|
|
assert calls == [], "an unconfigured page must not touch the network"
|
|
assert client.app.state.ctx.hub.jobs == {}, ( # type: ignore[attr-defined]
|
|
"no targets means no gateway job, so nothing can age out or go red")
|
|
|
|
|
|
@pytest.mark.parametrize("gateway_host", [None, "router"])
|
|
def test_gateway_snapshot_404s_when_the_name_is_not_a_target(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_host: str | None) -> None:
|
|
"""A name with no agent behind it is unconfigured, whatever the reason for the mismatch.
|
|
|
|
`None` is the trap this default creates: `GATEWAY_HOST` defaults to `gateway`, so a deployment
|
|
that monitors `deskwork` only must not grow a source dialling a machine nobody configured.
|
|
`"router"` is the opposite typo — a name set on purpose that the target list does not hold.
|
|
Both answer the same refusal with the same no-dial proof.
|
|
|
|
The two host jobs' first polls are awaited **before** the spies go in: the hub polls once the
|
|
moment it starts, and a spy that catches `host:deskwork` dialling its own dead target would be
|
|
a failure about a job this route never touched. With both intervals at an hour, the refusal is
|
|
measured against a hub that has already said everything it has to say.
|
|
"""
|
|
overrides: dict[str, Any] = {"host_targets": (Target("deskwork", DEAD_URL),),
|
|
"host_interval": 3_600.0, "disks_interval": 3_600.0}
|
|
if gateway_host is not None:
|
|
overrides["gateway_host"] = gateway_host
|
|
with build_client(tmp_path, monkeypatch, **overrides) as client:
|
|
wait_for_source_error(client, "host:deskwork", "unreachable")
|
|
wait_for_source_error(client, "disks:deskwork", "unreachable")
|
|
calls: list[str] = []
|
|
dial_spies(monkeypatch, calls)
|
|
|
|
body = client.get("/api/gateway/snapshot").json()
|
|
assert body["configured"] is False
|
|
assert "GATEWAY_HOST" in body["detail"], "the sentence names the fix"
|
|
assert calls == [], "the gate is in front of the network, not behind it"
|
|
jobs = client.app.state.ctx.hub.jobs # type: ignore[attr-defined]
|
|
assert "gateway" not in jobs, "an unconfigured name grew a job that could look red"
|
|
assert sorted(jobs) == ["disks:deskwork", "host:deskwork"]
|
|
|
|
|
|
def test_the_gateway_gate_keys_on_the_name_and_not_on_a_default(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The control for both refusals above: name the host that **is** a target and it opens.
|
|
|
|
Same fleet, one changed setting, and the route answers — with the stub, because the target is
|
|
still a dead port. Without this leg a broken router would look identical to a correct gate.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=(Target("deskwork", DEAD_URL),),
|
|
gateway_host="deskwork", host_interval=3_600.0) as client:
|
|
body = client.get("/api/gateway/snapshot").json()
|
|
assert body["configured"] is True and body["unreachable"] is True
|
|
assert body["host"] == "deskwork"
|
|
assert [row["key"] for row in body["states"]] == ["gateway"]
|
|
|
|
|
|
def test_gateway_snapshot_answers_the_pipes_the_projection_joined(
|
|
gateway_client, gateway_snapshot: Any) -> None:
|
|
"""The answering arm is `sources/gateway.parse`'s payload, key for key, plus `states`.
|
|
|
|
Two things are pinned at once. The **shape**: every key the page indexes is there, so a rename
|
|
in the source fails this test rather than printing `n/a` beside a live ingress. And the
|
|
**arithmetic** phase 30 task 02 replaced the old equality with:
|
|
`sum(listeners) + sum(distinct endpoints) <= established`, with the difference reported nowhere
|
|
— a socket neither config file describes is not a number on this page, and the phase deleted
|
|
the card that used to hold it. `sockets.established` stays the kernel's own row count beside
|
|
it, so the reader can see the gap rather than being told the two agree.
|
|
"""
|
|
body = gateway_client.get("/api/gateway/snapshot").json()
|
|
assert set(body) - {"states"} == gateway_payload_keys(gateway_snapshot("full")), (
|
|
"the route grew or lost a top-level key of the source's shape")
|
|
assert body["configured"] is True and body["supported"] is True
|
|
assert body["host"] == "gateway"
|
|
assert body["why"] is None, "a page with pipes has nothing to apologise for"
|
|
assert body["sections"] == {"haproxy": True, "nginx": True, "sockets": True}
|
|
assert not ({"outbound", "unmatched", "partial"} & set(body)), (
|
|
"the deleted join is back in the payload")
|
|
|
|
# the config half, verbatim from the capture the agent read (phase 28's six frontends, the
|
|
# stream map's 18 routing entries) — the echo the pipes were built from
|
|
assert len(body["haproxy"]["frontends"]) == 6
|
|
assert len(body["nginx"]["map_targets"]) == 18
|
|
# `/proc/net/tcp` is one row per live connection, so every count below is derived from the same
|
|
# capture the payload was built from instead of typed here: the rule is the pin, the number is a
|
|
# reading. The config-shaped figures above stay literal, because only the operator moves those.
|
|
established = gateway_snapshot("full")["sockets"]["established"]
|
|
|
|
stream = next(pipe for pipe in body["listeners"] if pipe["port"] == 443)
|
|
assert stream["connections"] == sum(1 for row in established
|
|
if row["local"]["port"] == 443), (
|
|
"the inbound pipe carries the sockets whose LOCAL port it is, which is the whole join")
|
|
assert stream["metric"] == "gateway.in_conns@443", (
|
|
"the pipe carries the series it is, so the page never rebuilds the name")
|
|
assert stream["clients"], "the inbound pipe names the addresses that are using it"
|
|
secure = next(pipe for pipe in body["listeners"] if pipe["port"] == 4443)
|
|
assert "nextcloud.reeseapps.com" in secure["domains"], "the listen port carries its server_names"
|
|
|
|
# The graph: every route the two captured configs declare, each line's inbound end a trunk this
|
|
# same payload draws, and a count on **only** the lines whose endpoint the config wrote as an
|
|
# address — this capture has exactly two (`127.0.0.1:4443` and `127.0.0.1:8081`).
|
|
assert len(body["connections"]) == 33
|
|
listener_ports = {listener["port"] for listener in body["listeners"]}
|
|
assert listener_ports == {22, 2000, 5201, 8080, 25565, 25566, 80, 443, 4443, 8443}
|
|
assert all(row["in"]["port"] in listener_ports for row in body["connections"]), (
|
|
"a connection on a port no listener carries is an orphan the page cannot draw")
|
|
def sockets_to(host: str, port: int) -> int:
|
|
return sum(1 for row in established
|
|
if (row["remote"]["addr"], row["remote"]["port"]) == (host, port))
|
|
|
|
counted = {row["id"]: row["connections"] for row in body["connections"]
|
|
if row["connections"] is not None}
|
|
assert counted == {"nginx:443->127.0.0.1:4443": sockets_to("127.0.0.1", 4443),
|
|
"nginx:8443->127.0.0.1:8081": sockets_to("127.0.0.1", 8081)}, (
|
|
"a route written as a domain carries no count, and one written as an address carries the "
|
|
"endpoint's own — 0 included, because the socket table answered")
|
|
assert all(row["out"]["address"] is True for row in body["connections"]
|
|
if row["connections"] is not None)
|
|
assert all(row["metric"] is None for row in body["connections"]
|
|
if row["connections"] is None), "no series for a number nobody can measure"
|
|
|
|
known_in = sum(pipe["connections"] for pipe in body["listeners"]
|
|
if pipe["connections"] is not None)
|
|
endpoints = {(row["out"]["host"], row["out"]["port"]) for row in body["connections"]
|
|
if row["connections"] is not None}
|
|
assert known_in + sum(counted.values()) <= body["sockets"]["established"] == len(established)
|
|
assert len(endpoints) == 2
|
|
assert [row["key"] for row in body["states"]] == ["gateway"]
|
|
|
|
|
|
def test_the_served_snapshot_counts_a_resolved_name_and_says_which_address_it_used(
|
|
resolved_gateway_client) -> None:
|
|
"""`/api/gateway/snapshot` on the 1.3.0 document: the dash is gone, and the reason is in the row.
|
|
|
|
One route, both halves of the claim. `brain.reeseapps.com` leaves this ingress for
|
|
`reeseapps-brain-of-reese.reeselink.com:443`; the row now carries `address: false` (the config
|
|
wrote a name — verbatim, still), `resolved: ["10.1.2.64"]`, `via: "libc"` and that entry's own
|
|
`resolved_ts`, **and** a finite `connections`. A number with no provenance beside it would be
|
|
the invention this app refuses to print, which is why the two halves are one test.
|
|
"""
|
|
body = resolved_gateway_client.get("/api/gateway/snapshot").json()
|
|
assert body["resolver"]["available"] is True and body["resolver"]["via"] == "libc"
|
|
brain = next(row for row in body["connections"]
|
|
if row["out"]["host"] == "reeseapps-brain-of-reese.reeselink.com")
|
|
assert brain["out"]["selectors"] == ["brain.reeseapps.com"]
|
|
assert brain["out"]["address"] is False
|
|
assert brain["out"]["resolved"] == ["10.1.2.64"]
|
|
assert brain["out"]["via"] == "libc"
|
|
assert isinstance(brain["out"]["resolved_ts"], (int, float))
|
|
assert isinstance(brain["connections"], int) and not isinstance(brain["connections"], bool), (
|
|
"resolved and idle is a `0`, not the dash: the socket table answered and this endpoint has none")
|
|
assert brain["metric"] == "gateway.out_conns@reeseapps-brain-of-reese.reeselink.com:443"
|
|
|
|
ntfy = next(row for row in body["connections"]
|
|
if row["out"]["host"] == "reeseapps-ntfy.reeselink.com")
|
|
assert ntfy["connections"] > 0, "the capture has live ntfy sockets behind that name"
|
|
counted = [row for row in body["connections"] if row["connections"] is not None]
|
|
assert len(counted) == len(body["connections"]), (
|
|
"every route this ingress declares is placed by the recorded resolver, so no route on the "
|
|
"live page is a dash any more — the operator's complaint, in one assertion")
|
|
assert all(row["out"]["address"] is True or row["out"]["resolved"] for row in counted), (
|
|
"a count never arrives without the address it was counted through — one the config wrote, "
|
|
"or one the gateway published; `resolved` stays null on the address rows, because copying "
|
|
"the agent's `literal` echo of a string the config already wrote would claim a lookup that "
|
|
"never happened")
|
|
|
|
|
|
def test_an_injected_nxdomain_row_is_a_dash_in_the_served_document(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Both arms on one route list: placed is a number, unplaced is `null` / `null`.
|
|
|
|
The capture resolves everything, so the second arm is injected: one entry of the recorded
|
|
document emptied and marked `nxdomain`, exactly what agent 1.3.0 publishes when `getaddrinfo`
|
|
says a name has no record. Its line keeps its place in the graph with `resolved: null`,
|
|
`via: null`, `connections: null` and `metric: null` — no `0`, no series, no sample — while the
|
|
routes around it carry their counts, so a page cannot read one failed name as a dead ingress.
|
|
"""
|
|
broken = gateway_snapshot("resolved")
|
|
entries = [entry for entry in broken["resolver"]["entries"]
|
|
if entry["name"] == "reeseapps-immich.reeselink.com"]
|
|
assert entries, "the capture resolves immich, so the injection has something to un-resolve"
|
|
for entry in entries:
|
|
entry["addresses"] = []
|
|
entry["status"] = "nxdomain"
|
|
payload = gateway_source.parse(broken, "gateway")
|
|
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
answer_as(client, "gateway", payload)
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
|
time.sleep(0.05)
|
|
body = client.get("/api/gateway/snapshot").json()
|
|
|
|
row = next(item for item in body["connections"]
|
|
if item["out"]["host"] == "reeseapps-immich.reeselink.com")
|
|
assert row["out"]["resolved"] is None, "never `[]`, so the page's dash arm is one `is null` test"
|
|
assert row["out"]["via"] is None and row["out"]["resolved_ts"] is None
|
|
assert row["connections"] is None and row["metric"] is None
|
|
assert row["in"] == {"port": 443}, "the route is still drawn: a name with no record is not no route"
|
|
placed = [item for item in body["connections"] if item["connections"] is not None]
|
|
assert len(placed) == len(body["connections"]) - 1, (
|
|
"one unresolved name dims one line and nothing else")
|
|
documented = {entry["name"]: entry["status"] for entry in body["resolver"]["entries"]}
|
|
assert documented["reeseapps-immich.reeselink.com"] == "nxdomain", (
|
|
"the entry the index refused to use is still the entry the page can quote")
|
|
|
|
|
|
def test_no_port_the_two_configs_do_not_name_appears_anywhere(gateway_client,
|
|
gateway_snapshot: Any) -> None:
|
|
"""The live router's own agent port is `9120`, and this payload never mentions it.
|
|
|
|
Phase 29 shipped a card that reported it. Phase 30 deleted the reporting: `9120` (and `2222`,
|
|
and everything else outside the two files) is a fact about the host that `/system` answers, and
|
|
filed under no pipe, no total and no warning here. Asserted over the **serialised** document,
|
|
because "not in a field I thought to check" is not the claim.
|
|
"""
|
|
snapshot = gateway_snapshot("full")
|
|
live = {row["port"] for row in snapshot["sockets"]["listens"]}
|
|
assert 9120 in live, "the capture must still contain the unclaimed port for this to mean anything"
|
|
body = gateway_client.get("/api/gateway/snapshot").json()
|
|
text = json.dumps(body)
|
|
assert ":9120" not in text and "@9120" not in text
|
|
assert 9120 not in {row["port"] for row in body["listeners"]}
|
|
assert 9120 not in {row["in"]["port"] for row in body["connections"]}
|
|
assert 9120 not in {row["out"]["port"] for row in body["connections"]}
|
|
|
|
|
|
def test_the_gateway_snapshot_carries_no_verdict_and_no_window(gateway_client) -> None:
|
|
"""A connection count is neither a graded number nor a rate, at any depth of the payload.
|
|
|
|
`severity.py` rates no `gateway.*` metric and the source is handed no `RateEstimator`, so the
|
|
route must not grow either either: a `severity` anywhere would colour a busy proxy (the
|
|
phase-06 GPU reasoning, applied to the ingress) and a `window_seconds` would claim a Δ was
|
|
measured when nothing was (rule 2).
|
|
"""
|
|
body = gateway_client.get("/api/gateway/snapshot").json()
|
|
assert body["listeners"] and body["connections"], (
|
|
"an empty walk would pass this pin for the wrong reason")
|
|
assert body["warnings"] == [], (
|
|
"this fleet's captured configs connect to themselves — the seven checks are not a lie "
|
|
"about the ingress the page draws")
|
|
assert find_keys(body, "severity") == [], "no count on this page is graded"
|
|
assert find_keys(body, "window_seconds") == [], "nothing here was measured over a window"
|
|
# `by_port` belonged to the join that credited a **port** for a target written as a name. The
|
|
# graph claims no join on a name at all, so the field has nowhere honest to live.
|
|
assert find_keys(body, "by_port") == [], "the port-only arm of the join is gone from the payload"
|
|
assert find_keys(body, "unmatched") == [], "no residual is reported, so no residual is labelled"
|
|
|
|
|
|
def test_gateway_snapshot_stubs_the_ingress_that_has_never_answered(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Configured, dialling, nothing in yet: the row stays and every count is unknown.
|
|
|
|
Invariant 4's page-side half, the `system.hosts` / `/api/disks` stub rule: dropping the host
|
|
would read as "never configured" and zeroing it would read as an idle gateway, so the answer
|
|
is `unreachable: true` with `null` counts and the `gateway` health record beside them. The
|
|
stub also has to carry **every key the answering payload carries** (plus its own
|
|
`unreachable`), or the page that renders both arms has to guess which keys exist.
|
|
"""
|
|
with make_gateway_client(tmp_path, monkeypatch) as client:
|
|
wait_for_source_error(client, "gateway", "unreachable")
|
|
body = client.get("/api/gateway/snapshot").json()
|
|
|
|
assert body["configured"] is True, "the source is in the deployment"
|
|
assert body["unreachable"] is True
|
|
assert body["host"] == "gateway", "the name is a config fact, so it survives the outage"
|
|
assert body["supported"] is None, "nobody has read its version"
|
|
assert body["listeners"] == [] and body["connections"] == []
|
|
assert body["haproxy"]["frontends"] == []
|
|
assert body["nginx"] == {"servers": [], "map_targets": []}
|
|
assert body["sockets"] == {"listens": None, "established": None}, "unknown, never 0"
|
|
assert not ({"outbound", "unmatched", "partial"} & set(body)), (
|
|
"the stub grew a key the deleted join needed")
|
|
assert body["why"] and "not answered" in body["why"], "a sentence the page can print"
|
|
assert body["ts"] is not None, "the age of the attempt is what the page prints"
|
|
assert [row["key"] for row in body["states"]] == ["gateway"]
|
|
|
|
|
|
def test_the_gateway_stub_and_the_answer_share_one_key_set(gateway_client) -> None:
|
|
"""The two arms differ in exactly two keys, so no page branch can index a missing one.
|
|
|
|
The stub cannot publish an `agent_version` (nobody answered, so nobody said) and must publish
|
|
its own `unreachable`. Everything else — every section, pipe list, tally and socket count the
|
|
answering arm carries — is there in both, which is what lets `/gateway` render one card body
|
|
for a live ingress and for a host that has never replied.
|
|
"""
|
|
answered = set(gateway_client.get("/api/gateway/snapshot").json()) - {"states"}
|
|
assert set(gateway_source.stub("gateway", ts=1.0)) == (
|
|
answered - {"agent_version"} | {"unreachable"})
|
|
|
|
|
|
def test_gateway_history_serves_the_pipe_gauges_the_store_holds(
|
|
gateway_client) -> None:
|
|
"""The points are the stored rows — read a third way, through `store.query`.
|
|
|
|
Phase 14 task 02's pin shape: the route goes through `_history_batch` → `store.history_many`
|
|
and the test reads `store.query`, and neither is the per-series loop the batch replaced, so an
|
|
"optimisation" that moves, drops or reorders a point fails here. Both gauge families are read,
|
|
the outbound one **with its port** in the name (two Minecraft backends on one host are two
|
|
facts), and the `source` says the `gateway` family — a page must never ask the 10-second `host`
|
|
series for a connection count.
|
|
|
|
The outbound name asked for is an **address** (`127.0.0.1:4443`) because this fixture answers as
|
|
a 1.2.0 agent. The rule `samples()` keeps is about the **address**, not the spelling: a route
|
|
publishes a gauge when the kernel can name its far end — an address the config wrote, or (since
|
|
phase 32 task 03, pinned beside this one on the `resolved` arm) a name the gateway itself
|
|
resolved. A name nobody resolved publishes nothing, which is what
|
|
`test_gateway_history_answers_empty_for_a_pipe_it_never_measured` still proves.
|
|
"""
|
|
store: SampleStore = gateway_client.app.state.ctx.store # type: ignore[attr-defined]
|
|
body = gateway_client.get("/api/gateway/history", params={
|
|
"hosts": "gateway",
|
|
"metrics": "gateway.in_conns@443,gateway.in_conns@4443,"
|
|
"gateway.out_conns@127.0.0.1:4443",
|
|
"range": "1h"}).json()
|
|
|
|
assert body["hosts"] == ["gateway"]
|
|
assert body["range_seconds"] == 3_600.0
|
|
assert [series["metric"] for series in body["series"]] == [
|
|
"gateway.in_conns@443", "gateway.in_conns@4443",
|
|
"gateway.out_conns@127.0.0.1:4443"], "host-major, metric-minor, request order"
|
|
assert series_source(body) == {"gateway"}, "its own family, never the system one"
|
|
for series in body["series"]:
|
|
rows = store.query("gateway", "gateway", series["metric"], res=RES_RAW)
|
|
assert rows, f"{series['metric']} has no stored row: the fixture wrote nothing"
|
|
assert [(point["ts"], point["value"]) for point in series["points"]] == rows
|
|
|
|
|
|
def test_gateway_history_answers_empty_for_a_pipe_it_never_measured(gateway_client) -> None:
|
|
"""Unknown names are an empty series, not a 404 — the contract the other routes have.
|
|
|
|
A card asks for every pipe it could draw in one request: a target with no traffic in the
|
|
window, a port the configs stopped naming, and a **domain** the source stopped storing because
|
|
nothing could ever count it all draw an empty plot while the series that do exist still answer.
|
|
"""
|
|
body = gateway_client.get("/api/gateway/history", params={
|
|
"hosts": "gateway",
|
|
"metrics": "gateway.in_conns@9999,gateway.out_conns@nowhere.test:1,"
|
|
"gateway.in_conns@443",
|
|
"range": "24h"}).json()
|
|
by_metric = {series["metric"]: series["points"] for series in body["series"]}
|
|
assert by_metric["gateway.in_conns@9999"] == []
|
|
assert by_metric["gateway.out_conns@nowhere.test:1"] == []
|
|
assert by_metric["gateway.in_conns@443"], "the series that does exist still answers"
|
|
assert series_source(body) == {"gateway"}
|
|
|
|
|
|
def test_a_resolved_name_publishes_its_gauge_and_history_answers_it(
|
|
resolved_gateway_client) -> None:
|
|
"""The name-keyed series phase 30 stopped writing, written again on an honest footing.
|
|
|
|
Phase 30 task 02 retired `gateway.out_conns@<domain>:<port>` because the only figure it could
|
|
have carried was the *port's* — the port-only join's count filed under whatever domain happened
|
|
to share it. Phase 32 task 03 gives the name a figure of its own: the sockets to the address the
|
|
**gateway** resolved that name to. So the name is stored, `/api/metrics` lists it, and
|
|
`/api/gateway/history` answers points for it — read through `store.query`, the third path, as
|
|
every other history pin is.
|
|
"""
|
|
store: SampleStore = resolved_gateway_client.app.state.ctx.store # type: ignore[attr-defined]
|
|
metric = "gateway.out_conns@reeseapps-ntfy.reeselink.com:443"
|
|
body = resolved_gateway_client.get("/api/gateway/history", params={
|
|
"hosts": "gateway", "metrics": metric, "range": "1h"}).json()
|
|
|
|
assert [series["metric"] for series in body["series"]] == [metric]
|
|
assert series_source(body) == {"gateway"}
|
|
rows = store.query("gateway", "gateway", metric, res=RES_RAW)
|
|
assert rows, "the resolved route's gauge was not stored"
|
|
assert [(point["ts"], point["value"]) for point in body["series"][0]["points"]] == rows
|
|
assert metric in resolved_gateway_client.get("/api/metrics",
|
|
params={"source": "gateway"}).json()["metrics"]
|
|
|
|
|
|
def test_one_shared_endpoint_costs_one_insert_per_poll_even_through_a_name(
|
|
tmp_path: Path, gateway_snapshot: Any) -> None:
|
|
"""Two lines, one gauge name, **one row per poll** — counted in the store, not asserted.
|
|
|
|
The captured ingress reaches `keycloak.reeselink.com:8080` from `:4443` and from `:8443`, and
|
|
since task 03 the endpoint is reachable through a **resolved name** rather than a written
|
|
address. `shared: true` says the two lines carry one number; `samples()` de-duplicating by
|
|
metric is what stops that number being inserted twice for one reading, and the row count is the
|
|
only proof worth having. Two polls, two rows: one per reading, never one per line.
|
|
"""
|
|
db = str(tmp_path / "gateway-shared.sqlite3")
|
|
store = SampleStore(db)
|
|
now = time.time()
|
|
metric = "gateway.out_conns@keycloak.reeselink.com:8080"
|
|
# both readings in the past: `store.query` bounds its window at now, so a simulated second poll
|
|
# that lands in the future is a row that never comes back, not a lost insert
|
|
for offset in (-60.0, -30.0):
|
|
snapshot = gateway_snapshot("resolved")
|
|
snapshot["ts"] = now + offset
|
|
result = gateway_source.parse_gateway_snapshot("gateway", snapshot, now=now + offset)
|
|
shared = [row for row in result.payload["connections"] if row["shared"]]
|
|
assert {row["metric"] for row in shared} == {metric}, (
|
|
"this capture's shared endpoint is the keycloak pair, and one poll writes it once")
|
|
assert len({row["connections"] for row in shared}) == 1, (
|
|
"both lines carry the same number, which is what makes one insert the honest count")
|
|
out = [row for row in result.samples if row.metric == metric]
|
|
assert len(out) == 1, "one sample per poll, however many lines reach the endpoint"
|
|
store.add_many(result.samples)
|
|
|
|
rows = store.query("gateway", "gateway", metric, res=RES_RAW)
|
|
assert len(rows) == 2, "two polls, two rows — not four (one per line) and not one (lost poll)"
|
|
store.close()
|
|
|
|
|
|
def test_gateway_history_names_the_ingress_and_no_other_host(gateway_client) -> None:
|
|
"""With no `hosts=` the answer is the one host this source reads — not the whole fleet.
|
|
|
|
The gauges are stored under the `GATEWAY_HOST` name, so defaulting to every `HOST_TARGETS`
|
|
entry would draw a card of empty plots for machines that were never asked to route, and
|
|
defaulting to nothing would leave the page's disclosure with nothing to ask for.
|
|
"""
|
|
body = gateway_client.get("/api/gateway/history",
|
|
params={"metrics": "gateway.in_conns@22"}).json()
|
|
assert body["hosts"] == ["gateway"]
|
|
assert body["range_seconds"] == 86_400.0, "the page's default window, like /disks"
|
|
assert [(series["host"], series["metric"]) for series in body["series"]] == [
|
|
("gateway", "gateway.in_conns@22")]
|
|
assert body["series"][0]["points"], "the gauge was stored under that very name"
|
|
|
|
|
|
def test_gateway_history_dials_nothing_when_the_gateway_is_unconfigured(
|
|
client, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""History reads only SQLite, so an unconfigured ingress costs no dial and no error.
|
|
|
|
The `/api/disks/history` rule: the configured gate belongs to the route that could reach the
|
|
network, and a page that asks for history anyway gets no hosts and no series rather than a
|
|
404 it has to special-case. The spies are the proof, not the empty body — an empty answer that
|
|
had dialled would be the invariant-3 failure with a 200 attached.
|
|
"""
|
|
calls: list[str] = []
|
|
dial_spies(monkeypatch, calls)
|
|
body = client.get("/api/gateway/history",
|
|
params={"metrics": "gateway.in_conns@443"}).json()
|
|
assert body["hosts"] == []
|
|
assert body["series"] == []
|
|
assert calls == []
|
|
|
|
|
|
def test_the_metrics_endpoint_answers_for_the_gateway_family(gateway_client) -> None:
|
|
""""Why is this chart empty" has an answer per page: /api/metrics?source=gateway.
|
|
|
|
The fifth family stays out of the 10-second `host` list, which is the other half of the same
|
|
promise — a connection count charted from the system series would be a number from a source
|
|
that never measured it.
|
|
"""
|
|
names = gateway_client.get("/api/metrics", params={"source": "gateway"}).json()["metrics"]
|
|
assert "gateway.in_conns@443" in names
|
|
assert not [name for name in names if not name.startswith("gateway.")], (
|
|
"the gateway family holds only gateway series")
|
|
assert not [name for name in gateway_client.get(
|
|
"/api/metrics", params={"source": "host"}).json()["metrics"]
|
|
if name.startswith("gateway.")], "a pipe's gauge never mixes into the system family"
|
|
|
|
|
|
def test_the_gateway_surface_is_gets_only_and_the_write_stays_the_ups_one(
|
|
gateway_client) -> None:
|
|
"""The `/gateway` surface grew exactly one write, and the app-wide write table named it.
|
|
|
|
Invariant 1's two upstream exceptions are `POST /api/ups/command` and `POST /api/gateway/route`
|
|
— nothing else on the served route table carries a `POST` outside the session and
|
|
certificate-authority rows, which touch only this process. The two read routes still refuse
|
|
every other verb: a `POST` to `/api/gateway/snapshot` is a 405, and the new route answers a
|
|
`GET` with a 405 of its own (it is a write, not a read).
|
|
"""
|
|
routes = flat_routes(gateway_client.app)
|
|
gate = [route for route in routes
|
|
if str(getattr(route, "path", "")).startswith("/api/gateway")]
|
|
assert {route.path for route in gate} == {"/api/gateway/snapshot",
|
|
"/api/gateway/history",
|
|
"/api/gateway/route"}
|
|
verbs = {verb for route in gate for verb in (getattr(route, "methods", None) or set())}
|
|
assert verbs == {"GET", "POST"}, "a second write, or a read lost its GET"
|
|
for path in ("/api/gateway/snapshot", "/api/gateway/history"):
|
|
for verb in ("POST", "PUT", "PATCH", "DELETE"):
|
|
response = gateway_client.request(verb, path, json={})
|
|
assert response.status_code == 405, f"{verb} {path} was accepted"
|
|
# The read verbs the write route has no use for: a `GET /api/gateway/route` is a 405,
|
|
# and so are `PUT`/`PATCH`/`DELETE` — the switch is a POST and nothing else.
|
|
for verb in ("GET", "PUT", "PATCH", "DELETE"):
|
|
response = gateway_client.request(verb, "/api/gateway/route")
|
|
assert response.status_code == 405, f"{verb} /api/gateway/route was accepted"
|
|
|
|
writes = sorted(str(route.path) for route in routes
|
|
if "POST" in (getattr(route, "methods", None) or set()))
|
|
upstream = [path for path in writes if not path.startswith(("/api/auth/", "/api/certs/"))]
|
|
assert upstream == ["/api/gateway/route", "/api/ups/command"], (
|
|
"the upstream write table changed — invariant 1 names exactly two exceptions")
|
|
|
|
|
|
# ---- the second upstream write: `POST /api/gateway/route` (phase 33 task 05) ---- #
|
|
#
|
|
# The ten-gate design, pinned the way the UPS one is: a recorder standing in for
|
|
# `collectors.post_route` answers "did the request reach the peer?" instead of a status code,
|
|
# and every refusal asserts the recorder is empty **and** the event the gate named. The payload
|
|
# is the captured tree through `sources/gateway` (one more `marked` arm for the `#off` rows),
|
|
# the gateway and host jobs are marked fresh, and the target points at a dead port — the only
|
|
# way out of the handler is the recorder.
|
|
|
|
#: One on-route and one map route out of the captured `full` payload; the helper's wire id is
|
|
#: the base, and both spellings (with/without the `#off` suffix) must be accepted.
|
|
BRAIN_ID = "nginx:443->reeseapps-brain-of-reese.reeselink.com:443"
|
|
GITEA_OFF_ID = "haproxy:22->reeseapps-gitea.reeselink.com:2222#off"
|
|
|
|
#: A fresh database name per `_route_app` call (see the fixture's docstring).
|
|
_route_app_counter = itertools.count(1)
|
|
|
|
|
|
class RouteSpy:
|
|
"""The recorder for `dashboards.api.post_route`.
|
|
|
|
Records every dial and answers from `answer` (a dict) or raises `error` — the same seam
|
|
`ScriptedNutClient` gives the UPS write, so "refused before the dial" is a fact this file
|
|
can state rather than infer. A dial-then-refuse would put a config byte on the peer.
|
|
"""
|
|
|
|
def __init__(self, answer: dict[str, Any] | None = None,
|
|
error: UpstreamError | None = None) -> None:
|
|
self.answer = answer
|
|
self.error = error
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def __call__(self, client: Any, target: Any, body: Any, *,
|
|
timeout: float | None = None, tls: Any = None) -> dict[str, Any]:
|
|
self.calls.append({"target": target, "body": dict(body), "timeout": timeout,
|
|
"tls": tls})
|
|
if self.error is not None:
|
|
raise self.error
|
|
assert self.answer is not None, "a spy with neither an answer nor an error"
|
|
return self.answer
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _route_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any,
|
|
*, mode: str = "full", writes: bool = True, read_only: bool = False,
|
|
signed_in: bool = True,
|
|
spy: RouteSpy) -> Iterator[TestClient]:
|
|
"""One app per case: signed in (unless the case is the anonymous gate), payload injected,
|
|
both jobs marked fresh, and `api.post_route` the recorder — the only way a request gets
|
|
past gate 10. A fresh database name per call: `recent_events` reads the rows, and two
|
|
arms of one test sharing a file would see each other's events."""
|
|
db = str(tmp_path / f"route-{next(_route_app_counter)}-{mode}-{read_only}-{writes}.sqlite3")
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2, host_targets=(GATEWAY_TARGET,),
|
|
gateway_allow_writes=writes, read_only=read_only)
|
|
store = SampleStore(db)
|
|
app = create_app(settings, store)
|
|
with TestClient(app) as client:
|
|
if signed_in:
|
|
sign_in(client)
|
|
now = time.time()
|
|
snapshot = gateway_snapshot(mode)
|
|
snapshot["ts"] = now
|
|
result = gateway_source.parse_gateway_snapshot("gateway", snapshot, now=now)
|
|
store.add_many(result.samples)
|
|
job = app.state.ctx.hub.jobs["gateway"]
|
|
job.state.payload = result.payload
|
|
job.state.ok = True
|
|
job.state.last_ok = now
|
|
host_job = app.state.ctx.hub.jobs.get("host:gateway")
|
|
if host_job is not None:
|
|
host_job.state.ok = True
|
|
monkeypatch.setattr(api, "post_route", spy)
|
|
yield client
|
|
store.close()
|
|
|
|
|
|
def _gateway_events(client: TestClient) -> list[dict[str, Any]]:
|
|
# The store, not the HTTP route: an anonymous client (gate 1) cannot call `/api/events`.
|
|
return [e.as_json() for e in client.app.state.ctx.store.recent_events(source="gateway") # type: ignore[attr-defined]
|
|
]
|
|
|
|
|
|
def _route_body(client: TestClient, **overrides: Any) -> httpx.Response:
|
|
body = {"id": BRAIN_ID, "action": "disable", "danger": "DANGER"}
|
|
body.update(overrides)
|
|
return client.post("/api/gateway/route", json=body)
|
|
|
|
|
|
@pytest.mark.parametrize("gate, setup, body, headers, status, detail", [
|
|
(1, {"signed_in": False}, None, None, 401, "sign in required"),
|
|
(2, {}, None, {"X-CSRF-Token": ""}, 403, "csrf"),
|
|
(3, {}, None, {"Origin": "https://evil.example"}, 403, "cross-origin"),
|
|
(7, {}, {"id": "", "action": "disable", "danger": "DANGER"}, None, 422,
|
|
"`id` must be a non-empty string"),
|
|
(7, {}, {"id": BRAIN_ID, "action": "disable", "danger": "DANGER", "extra": 1},
|
|
None, 422, "unknown key 'extra'"),
|
|
(7, {}, {"id": BRAIN_ID, "action": "toggle", "danger": "DANGER"}, None, 422,
|
|
"`action` must be `disable` or `enable`"),
|
|
(7, {}, {"id": BRAIN_ID, "action": "disable", "danger": "DANGER", "dry_run": "yes"},
|
|
None, 422, "`dry_run` must be a boolean"),
|
|
(8, {}, {"id": "nginx:443->nope.example.com:443", "action": "disable",
|
|
"danger": "DANGER"}, None, 409, "not in the current payload"),
|
|
])
|
|
def test_each_gate_refuses_without_touching_the_peer(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any,
|
|
gate: int, setup: dict[str, Any], body: dict[str, Any] | None,
|
|
headers: dict[str, str] | None, status: int, detail: str) -> None:
|
|
"""Gates 1-3 in the middleware, 7-8 in the route — each one with the peer standing in.
|
|
|
|
Every case asserts three facts, all inside the app's window: the recorder is empty
|
|
(nothing reached the agent), the answer is the gate's own sentence, and — for the
|
|
in-handler gates — one `route-denied` row names the gate. (Gates 4, 5, 6, 9 and 10 are
|
|
the named tests that follow; the middleware gates write no route event at all.)
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy, **setup) as client:
|
|
if gate == 2:
|
|
del client.headers[auth.CSRF_HEADER]
|
|
response = (client.post("/api/gateway/route",
|
|
json={"id": BRAIN_ID, "action": "disable",
|
|
"danger": "DANGER"},
|
|
headers=headers or {})
|
|
if body is None else
|
|
client.post("/api/gateway/route", json=body, headers=headers or {}))
|
|
assert response.status_code == status, response.text
|
|
assert detail in response.json()["detail"].lower()
|
|
assert spy.calls == [], f"gate {gate} let a request through to the peer"
|
|
events = _gateway_events(client)
|
|
if gate in (1, 2, 3):
|
|
assert not any("denied at gate" in str(row["message"]) for row in events), (
|
|
"the middleware refuses before the handler: it writes no route event")
|
|
else:
|
|
gate_events = [row for row in events
|
|
if row["kind"] == "route-denied"
|
|
and f"gate {gate}" in str(row["message"])]
|
|
assert len(gate_events) == 1, f"gate {gate} wrote {len(gate_events)} events, not one"
|
|
assert gate_events[0]["severity"] == "error"
|
|
|
|
|
|
def test_read_only_refuses_and_names_the_variable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 4: `DASH_READ_ONLY` beats the flag — a deployment that may write may still be
|
|
read-only, and the sentence says which variable refused, so `grep DASH_READ_ONLY`
|
|
finds both write paths.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, read_only=True, spy=spy) as client:
|
|
response = _route_body(client)
|
|
assert response.status_code == 403
|
|
assert "DASH_READ_ONLY" in response.json()["detail"]
|
|
assert spy.calls == []
|
|
assert [row for row in _gateway_events(client) if "gate 4" in str(row["message"])]
|
|
|
|
|
|
def test_the_writes_flag_off_refuses_and_names_its_own_variable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 5, its own sentence: the NUT flag does not carry this write (two decisions),
|
|
and the refusal names `GATEWAY_ALLOW_WRITES` — the variable an operator actually sets.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, writes=False, spy=spy) as client:
|
|
response = _route_body(client)
|
|
assert response.status_code == 403
|
|
assert "GATEWAY_ALLOW_WRITES" in response.json()["detail"]
|
|
assert spy.calls == []
|
|
assert [row for row in _gateway_events(client) if "gate 5" in str(row["message"])]
|
|
|
|
|
|
def test_the_write_throttle_blocks_after_five_failed_dials_and_not_before(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 6 on the wire: the `route:<address>` key is the throttle's third shape, `429` +
|
|
`Retry-After` is its answer, and the block is read-only — a refused request does not
|
|
extend its own block by asking.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
ctx = client.app.state.ctx # type: ignore[attr-defined]
|
|
assert ctx.login_throttle.retry_after("route:testclient") == 0.0
|
|
for _ in range(5):
|
|
ctx.login_throttle.failure("route:testclient")
|
|
blocked = _route_body(client)
|
|
assert blocked.status_code == 429
|
|
assert int(blocked.headers["Retry-After"]) >= 59
|
|
assert blocked.json()["detail"].startswith("too many route writes")
|
|
# `retry_after` is read-only: the refusal itself added nothing to the block.
|
|
assert ctx.login_throttle.retry_after("route:testclient") <= 60.0
|
|
assert spy.calls == []
|
|
assert [row for row in _gateway_events(client) if "gate 6" in str(row["message"])]
|
|
|
|
|
|
def test_the_body_gate_answers_ours_not_the_frameworks(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 7's remaining arms: one fixed sentence naming the contract, whatever the body's
|
|
shape is wrong in. A framework's per-field schema errors would make a caller diff the
|
|
framework's wording to fix the request; the hand-parsed body keeps the 422 the route
|
|
owns.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
not_json = client.post("/api/gateway/route", content=b"not json",
|
|
headers={"Content-Type": "application/json"})
|
|
assert not_json.status_code == 422
|
|
assert ("send a JSON body with `id`, `action` and `danger`"
|
|
in not_json.json()["detail"])
|
|
for body in ({"id": 5, "action": "disable", "danger": "DANGER"},
|
|
{"id": BRAIN_ID + "x" * 256, "action": "disable", "danger": "DANGER"},
|
|
{"id": BRAIN_ID, "action": "disable", "dry_run": 1, "danger": "DANGER"}):
|
|
response = client.post("/api/gateway/route", json=body)
|
|
assert response.status_code == 422, body
|
|
assert spy.calls == []
|
|
gate7 = [row for row in _gateway_events(client)
|
|
if row["kind"] == "route-denied" and "gate 7" in str(row["message"])]
|
|
assert len(gate7) == 4, "one event per bad-body refusal, no more"
|
|
|
|
|
|
def test_a_route_that_is_already_off_says_so_and_dials_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 8's no-op arm, both directions, on the captured payload.
|
|
|
|
Enabling a route the payload already shows on (`full`), or disabling one it shows `#off`
|
|
(`marked`, both spellings of the id), is a `409 {changed: false}` — the switch state is
|
|
the payload's, and a write that changes nothing is not a write this app sends.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID[:-4], "action": "enable",
|
|
"danger": "DANGER"})
|
|
assert response.status_code == 409, response.text
|
|
assert response.json()["changed"] is False
|
|
assert spy.calls == []
|
|
spy_off = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, mode="marked", spy=spy_off) as client:
|
|
for spelling in (GITEA_OFF_ID, GITEA_OFF_ID[:-4]):
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": spelling, "action": "disable",
|
|
"danger": "DANGER"})
|
|
assert response.status_code == 409, f"{spelling}: {response.text}"
|
|
assert response.json()["changed"] is False
|
|
assert spy_off.calls == []
|
|
gate8 = [row for row in _gateway_events(client)
|
|
if row["kind"] == "route-denied" and "gate 8" in str(row["message"])]
|
|
assert len(gate8) == 2, "one event per no-op refusal, both spellings named"
|
|
|
|
|
|
def test_a_route_that_is_off_and_able_to_be_enabled_dials_with_the_base_id(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The inverse of the no-op: the `marked` payload shows the gitea route `#off`, so
|
|
`enable` is a real change — it dials, once, with the base id (the suffix is the
|
|
dashboard's spelling, not the helper's).
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, mode="marked", spy=spy) as client:
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID, "action": "enable",
|
|
"danger": "DANGER"})
|
|
assert response.status_code == 200, response.text
|
|
assert len(spy.calls) == 1
|
|
assert spy.calls[0]["body"] == {"id": GITEA_OFF_ID[:-4], "action": "enable",
|
|
"dry_run": False}
|
|
|
|
|
|
def test_a_stale_gateway_or_dead_host_job_refuses_the_write(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 9's seven arms: "do not rewrite a config you cannot currently read end to
|
|
end". Every arm is a `409` whose sentence and event name the one fact that refused —
|
|
the job's health, its last answer, the section reads, and the agent's version. The
|
|
payload is the captured `full` tree, so the arms are state mutations, not captures.
|
|
"""
|
|
cases = [
|
|
(lambda app: _mutate_job(app, "ok", False), "the gateway job is not ok"),
|
|
(lambda app: _mutate_job(app, "last_ok", None), "the gateway has not answered"),
|
|
(lambda app: _mutate_job(app, "last_ok_stale", None), "the gateway is stale"),
|
|
(lambda app: _mutate_job(app, "host_ok", False), "the host job is not ok"),
|
|
(lambda app: _mutate_sections(app, "haproxy", None), "the haproxy section is not read"),
|
|
(lambda app: _mutate_sections(app, "nginx", None), "the nginx section is not read"),
|
|
(lambda app: _mutate_version(app, "1.3.9"), "the agent predates the marker read"),
|
|
]
|
|
for mutate, sentence in cases:
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
mutate(client.app)
|
|
response = _route_body(client)
|
|
assert response.status_code == 409, f"{sentence}: {response.text}"
|
|
assert sentence in response.json()["detail"]
|
|
assert ("do not rewrite a config you cannot currently read"
|
|
in response.json()["detail"])
|
|
assert spy.calls == [], f"{sentence} let a request through to the peer"
|
|
events = [row for row in _gateway_events(client)
|
|
if row["kind"] == "route-denied" and "gate 9" in str(row["message"])]
|
|
assert len(events) == 1, f"{sentence}: the gate's own event is missing or doubled"
|
|
|
|
|
|
def _mutate_job(app: Any, what: str, value: Any) -> None:
|
|
job = app.state.ctx.hub.jobs["gateway"]
|
|
if what == "ok":
|
|
job.state.ok = value
|
|
elif what == "last_ok":
|
|
job.state.last_ok = value
|
|
elif what == "last_ok_stale":
|
|
job.state.last_ok = time.time() - 4 * job.interval
|
|
else:
|
|
app.state.ctx.hub.jobs["host:gateway"].state.ok = value
|
|
|
|
|
|
def _mutate_sections(app: Any, section: str, value: Any) -> None:
|
|
app.state.ctx.hub.jobs["gateway"].state.payload["sections"][section] = value
|
|
|
|
|
|
def _mutate_version(app: Any, version: str) -> None:
|
|
app.state.ctx.hub.jobs["gateway"].state.payload["agent_version"] = version
|
|
|
|
|
|
def test_danger_is_required_to_enable_as_well_as_disable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 10, both verbs: switching a route off and switching one back on are both
|
|
writes to the fleet's only ingress, and the typed word is the confirmation for both —
|
|
the UPS `confirm="DANGER"` sentence's sibling, refusing with the same 422. Each verb
|
|
is tested on a payload where the request is a real change (gate 8's 409 would
|
|
otherwise answer first), so the 422 is gate 10's own.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
# `full` shows the gitea route on: `disable` is the real change.
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID[:-4], "action": "disable"})
|
|
assert response.status_code == 422
|
|
assert 'danger="DANGER"' in response.json()["detail"]
|
|
spy2 = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, mode="marked", spy=spy2) as client:
|
|
# `marked` shows it off: `enable` is the real change.
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID[:-4], "action": "enable"})
|
|
assert response.status_code == 422
|
|
assert 'danger="DANGER"' in response.json()["detail"]
|
|
assert [row for row in _gateway_events(client)
|
|
if "gate 10" in str(row["message"])]
|
|
assert spy.calls == [] and spy2.calls == []
|
|
|
|
|
|
def test_a_dry_run_asks_nothing_and_writes_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Gate 10's one exemption: the dry run is the preview the page asks for **before**
|
|
the operator types the word, and the helper's dry run writes no byte — so the word
|
|
has nothing to confirm. Without the exemption the preview the dialog leads with would
|
|
be a 422 and the shipped flow (diff, then the word, then the one send) could not
|
|
start. The exemption is the dry run's alone: the same body with `dry_run: false`
|
|
still needs the word (the test above), and the answer is the helper's object
|
|
verbatim — a diff, not a write.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": False, "dry_run": True, "changed": False,
|
|
"validated": True,
|
|
"diff": "- server gitea reeseapps-gitea.reeselink.com:2222 check inter 5s\n"
|
|
"+ server gitea reeseapps-gitea.reeselink.com:2222 check inter 5s #off gitea"})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID[:-4], "action": "disable",
|
|
"dry_run": True})
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["dry_run"] is True
|
|
assert body["applied"] is False, "a dry run answers the helper's word, not a write"
|
|
assert body["diff"].startswith("- server gitea"), "the diff comes back verbatim"
|
|
assert "dry run" in body["hint"], body["hint"]
|
|
assert len(spy.calls) == 1
|
|
assert spy.calls[0]["body"]["dry_run"] is True
|
|
# the dry run writes no route event: it changed nothing and refused nothing
|
|
assert not [row for row in _gateway_events(client) if row["kind"].startswith("route-")]
|
|
|
|
|
|
def test_a_successful_disable_dials_once_and_writes_its_own_event(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The happy path, pinned both ways: what goes out the door, and what the caller gets
|
|
back. Out: the wire body is `id`/`action`/`dry_run` (the base, no `#off`, no
|
|
`danger`), one call, on the write's own 35 s budget. Back: the helper's own object
|
|
**verbatim** — the `diff` field it carried comes back to the caller untouched — plus
|
|
`host`/`id`/`action`/`dry_run` and the module's hint. The event is the fixed shape,
|
|
and the `diff` the helper returned is **not** in it: `/api/events` is a feed the
|
|
browser reads, and config text is not a log line.
|
|
"""
|
|
diff_text = "server gitea-2222 gitea:2222 disabled" # the helper's detail, not ours
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True,
|
|
"diff": diff_text})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = _route_body(client)
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["applied"] is True and body["changed"] is True
|
|
assert body["reloaded"] is True
|
|
assert body["diff"] == diff_text, "the helper's own object is handed back, verbatim"
|
|
assert body["host"] == "gateway" and body["id"] == BRAIN_ID
|
|
assert body["action"] == "disable" and body["dry_run"] is False
|
|
assert "the next gateway poll" in body["hint"]
|
|
assert len(spy.calls) == 1
|
|
assert spy.calls[0]["body"] == {"id": BRAIN_ID, "action": "disable",
|
|
"dry_run": False}, (
|
|
"the wire is task 01's `--serve` contract: no `danger`, no suffix")
|
|
assert spy.calls[0]["timeout"] == 35.0, "the write's own budget, not the read's"
|
|
# The app's own certificate material, wired through for the gateway host: the write
|
|
# dials on the same identity the jobs do (the plain target leaves it unused; the
|
|
# dial-side use is `test_collectors`' job).
|
|
tls = spy.calls[0]["tls"]
|
|
assert tls is not None
|
|
assert tls.verify == str(client.app.state.ctx.certs.ca # type: ignore[attr-defined]
|
|
), "the app's own CA, not a token"
|
|
events = _gateway_events(client)
|
|
disabled = [row for row in events if row["kind"] == "route-disabled"]
|
|
assert len(disabled) == 1 and not [row for row in events
|
|
if row["kind"] == "route-enabled"]
|
|
assert disabled[0]["severity"] == "warn"
|
|
assert disabled[0]["message"] == ("disable brain.reeseapps.com → "
|
|
"reeseapps-brain-of-reese.reeselink.com:443: "
|
|
"applied=true validated=false reloaded=true (admin)")
|
|
assert diff_text not in str(disabled[0]["message"]), "no config text in the feed"
|
|
assert diff_text not in str(disabled[0]["data"]), "no config text in the feed"
|
|
# The attempt completed: the backoff resets.
|
|
assert client.app.state.ctx.login_throttle.retry_after("route:testclient") == 0.0 # type: ignore[attr-defined]
|
|
|
|
|
|
def test_a_helper_refusal_writes_its_denied_event_and_resets_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""An `applied: false` that reached the helper and came back is the helper's word,
|
|
passed over: 200 with the refusal, one `route-denied` event, and the throttle
|
|
untouched — the caller did nothing wrong and the helper said so, so neither a success
|
|
nor a failure is recorded.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": False, "changed": False, "drift": True,
|
|
"hint": "the config on disk differs"})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = _route_body(client)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["applied"] is False and response.json()["changed"] is False
|
|
assert "no byte of config was written" in response.json()["hint"]
|
|
assert len(spy.calls) == 1
|
|
events = _gateway_events(client)
|
|
denied = [row for row in events if row["kind"] == "route-denied"]
|
|
assert len(denied) == 1 and denied[0]["severity"] == "error"
|
|
assert "applied=false" in denied[0]["message"]
|
|
throttle = client.app.state.ctx.login_throttle # type: ignore[attr-defined]
|
|
assert throttle.attempts("route:testclient") == 0
|
|
assert throttle.tracked == 0, "neither a success nor a failure recorded"
|
|
|
|
|
|
def test_a_failed_dial_charges_the_throttle_and_writes_the_denied_event(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The dial's own failure is a `route-denied` that names the dial, and it is the one
|
|
arm that charges the throttle: the peer said no (or never said anything), and the
|
|
backoff is what stands between the next attempt and the same failure.
|
|
"""
|
|
spy = RouteSpy(error=UpstreamError(
|
|
"gateway: http://agent.test/route -> HTTP 409 (no such id)"))
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = _route_body(client)
|
|
assert response.status_code == 502
|
|
assert "no such id" in response.json()["detail"]
|
|
events = _gateway_events(client)
|
|
denied = [row for row in events if row["kind"] == "route-denied"]
|
|
assert len(denied) == 1
|
|
assert "refused at the dial" in denied[0]["message"]
|
|
assert client.app.state.ctx.login_throttle.attempts("route:testclient") == 1 # type: ignore[attr-defined]
|
|
|
|
|
|
def test_dry_run_dials_the_helper_and_writes_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""`dry_run` is the helper's job, not the dashboard's: the request dials (past the nine
|
|
gates that precede the word — phase 33 task 06 made the dry run the one body the typed
|
|
`danger` does not gate: the preview the dialog leads with must reach the helper before
|
|
the operator types it), the flag travels on the wire, and the helper's no-write answer
|
|
comes back verbatim. No route event: a rehearsal changed nothing and refused nothing, so
|
|
the feed keeps its one-line-per-outage shape instead of growing an error line per
|
|
preview.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": False, "dry_run": True, "changed": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": BRAIN_ID, "action": "disable",
|
|
"danger": "DANGER", "dry_run": True})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["dry_run"] is True and response.json()["applied"] is False
|
|
assert "dry run" in response.json()["hint"]
|
|
assert spy.calls[0]["body"]["dry_run"] is True
|
|
assert not [row for row in _gateway_events(client)
|
|
if row["kind"].startswith("route-")] # a rehearsal writes no route event
|
|
assert client.app.state.ctx.login_throttle.tracked == 0 # type: ignore[attr-defined]
|
|
|
|
|
|
def test_the_writes_enabled_flag_reaches_the_page_config(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""`/api/config` tells the page the flag's state, so the UI can dim the knob on a
|
|
deployment that did not enable it — the `writes_enabled` field, on and off.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
assert client.get("/api/config").json()["gateway"]["writes_enabled"] is True
|
|
spy_off = RouteSpy(answer={"applied": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, writes=False, spy=spy_off) as client:
|
|
assert client.get("/api/config").json()["gateway"]["writes_enabled"] is False
|
|
|
|
|
|
def test_the_wire_id_is_the_base_with_or_without_the_off_spelling(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""Both spellings are accepted (gate 8) and the wire gets the base: a `#off` id in the
|
|
`full` payload (where the route is on) is a no-op 409; in `marked` the same id is a
|
|
real `enable`, and the helper dials with the suffix stripped.
|
|
"""
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID, "action": "enable",
|
|
"danger": "DANGER"})
|
|
assert response.status_code == 409, response.text
|
|
assert response.json()["changed"] is False
|
|
assert spy.calls == []
|
|
spy2 = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, mode="marked", spy=spy2) as client:
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": GITEA_OFF_ID, "action": "enable",
|
|
"danger": "DANGER"})
|
|
assert response.status_code == 200, response.text
|
|
assert spy2.calls[0]["body"] == {"id": GITEA_OFF_ID[:-4], "action": "enable",
|
|
"dry_run": False}
|
|
|
|
|
|
def test_a_revoked_agent_refuses_the_write_without_a_socket(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""The revocation gate is in front of the network on the write path too (invariant 1,
|
|
the phase-15 rule): a host the operator struck out is not a host a write may touch, and
|
|
the refusal costs no network round trip. The dial is the **real** `post_route` here —
|
|
only the transport is spied, so the proof is "no request at all", the way the read
|
|
side's revocation proof states it. The target is `https://` so the write takes the
|
|
certificate path, where the revocation is the only check that runs.
|
|
"""
|
|
db = str(tmp_path / "route-revoked.sqlite3")
|
|
settings = Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("gateway", "https://127.0.0.1:1"),),
|
|
gateway_allow_writes=True)
|
|
store = SampleStore(db)
|
|
app = create_app(settings, store)
|
|
with TestClient(app) as client:
|
|
sign_in(client)
|
|
now = time.time()
|
|
snapshot = gateway_snapshot("full")
|
|
snapshot["ts"] = now
|
|
result = gateway_source.parse_gateway_snapshot("gateway", snapshot, now=now)
|
|
store.add_many(result.samples)
|
|
job = app.state.ctx.hub.jobs["gateway"]
|
|
job.state.payload = result.payload
|
|
job.state.ok = True
|
|
job.state.last_ok = now
|
|
host_job = app.state.ctx.hub.jobs.get("host:gateway")
|
|
if host_job is not None:
|
|
host_job.state.ok = True
|
|
state = certs.issue_agent_cert(store, certs.ensure_certs(store), "gateway",
|
|
["127.0.0.1"])
|
|
certs.revoke(store, state, "gateway")
|
|
posted: list[str] = []
|
|
|
|
async def spy_post(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
posted.append(str(url))
|
|
raise AssertionError(f"a revoked agent was dialled for a write at {url}")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "post", spy_post)
|
|
response = client.post("/api/gateway/route",
|
|
json={"id": BRAIN_ID, "action": "disable",
|
|
"danger": "DANGER"})
|
|
assert response.status_code == 502, response.text
|
|
assert "certificate revoked" in response.json()["detail"]
|
|
assert posted == [], "the gate is in front of the network, not behind it"
|
|
denied = [row for row in _gateway_events(client) if row["kind"] == "route-denied"]
|
|
assert len(denied) == 1
|
|
store.close()
|
|
|
|
|
|
def test_no_diff_or_config_text_reaches_an_event(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
|
"""A successful write whose helper answer carries a `diff` field: the event line may
|
|
not leak it. `/api/events` is a feed the browser reads, and a config's bytes are not a
|
|
log line — so no `diff`, no `--- `/`+++ ` hunk line, no path, and no `was:` marker in
|
|
the message or the data. The caller still gets the diff (the response is the helper's
|
|
object, verbatim); the feed is what is kept clean.
|
|
"""
|
|
diff_text = ("--- a /etc/haproxy/haproxy.cfg\n"
|
|
"+++ b /etc/haproxy/haproxy.cfg\n"
|
|
"- server gitea-2222 gitea:2222\n"
|
|
"+ server gitea-2222 gitea:2222 disable # was: on")
|
|
spy = RouteSpy(answer={"applied": True, "changed": True, "reloaded": True,
|
|
"diff": diff_text})
|
|
with _route_app(tmp_path, monkeypatch, gateway_snapshot, spy=spy) as client:
|
|
response = _route_body(client)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["diff"] == diff_text, "the caller gets the helper's object"
|
|
events = _gateway_events(client)
|
|
assert any(row["kind"] == "route-disabled" for row in events)
|
|
for row in events:
|
|
text = str(row["message"]) + str(row["data"])
|
|
assert diff_text not in text, "the helper's diff is not a log line"
|
|
assert "--- " not in text and "+++ " not in text, "no hunk line in the feed"
|
|
assert "haproxy.cfg" not in text, "no config path in the feed"
|
|
assert "was:" not in text, "no marker in the feed"
|
|
|
|
|
|
def test_the_write_timeout_is_longer_than_the_agents_peer_timeout() -> None:
|
|
"""The ordering, not the numbers.
|
|
|
|
The helper's call is validate, edit one line, reload the proxy: the write budget must
|
|
outlive the 30 s the agent gives a peer request on the same web server, on purpose — a
|
|
dashboard that times out first leaves the operator guessing whether the reload happened.
|
|
The read budget stays the read's (10 s); the two never share a number.
|
|
"""
|
|
settings = Settings()
|
|
assert settings.gateway_write_timeout > 30.0, (
|
|
"the write budget must outlive the agent's 30 s peer timeout")
|
|
assert settings.gateway_write_timeout > settings.gateway_timeout
|
|
|
|
|
|
# ---- the `/gateway` page (phase 29 task 04) -------------------------------- #
|
|
#
|
|
# The fifth dashboard, asserted the way the other four are: on the served bytes, because a JS
|
|
# test runner would be a build step (a locked anchor). What the page *is* decides what is worth
|
|
# pinning. It draws **pipes**, and a pipe's only claim is a connection count, so the pins are: the
|
|
# geometry animates while that count is at least one and rests at zero (a static drawing of a live
|
|
# connection reads as idle, and an animated zero reads as traffic — both are the lie this page can
|
|
# tell); every pipe says what it is to assistive tech, because a moving line says nothing to a
|
|
# screen reader; nothing is coloured by a verdict, because `severity.py` rates no `gateway.*`
|
|
# metric and the API sends none; every label is the config's own text, so a rename in
|
|
# `sources/gateway.py` fails here instead of printing `n/a` beside a healthy ingress. Phase 30
|
|
# task 02 removed the last one of those — the unreconciled remainder is no longer printed, because
|
|
# a socket outside the two config files is not this page's business — so the pins below assert its
|
|
# **absence** beside the rules that survived. `data/logs/29-task04-pipes.cjs` is the browser half.
|
|
|
|
#: Every payload path the page's script indexes or keys a row by, in the arm that measured. Resolved
|
|
#: against a live `GET /api/gateway/snapshot` below rather than written from the source, so the two
|
|
#: sides of the join are checked against each other (`SLOT_FIELDS`' / `COLUMNS`' device). Phase 30
|
|
#: task 04 dropped `sockets.listens` / `sockets.established` (the kernel's table size is on no cell
|
|
#: any more — a tally the map cannot reconcile is one an operator learns to skip) and
|
|
#: `nginx.map_targets.*` / `haproxy.frontends.*` (neither is a row any more: the fan-out arrives as
|
|
#: `connections[]` carrying its own `out.kind`, which
|
|
#: `test_the_stream_map_is_rendered_inside_nginx_and_nowhere_else` pins), and added `warnings.*`.
|
|
#: Phase 31 task 02 put the four `connections.out.*` paths to a second use: they are exactly what the
|
|
#: leaf row's two text helpers index (`inboundText` → `selectors` then `kind`, `counterpartText` →
|
|
#: `host` + `port`), so `test_the_leaf_row_leads_with_the_inbound_word` resolves the same tuple the
|
|
#: page reads against a live document and a rename fails by name rather than printing `undefined`.
|
|
GATEWAY_PAGE_FIELDS = (
|
|
"host", "supported", "why", "agent_version",
|
|
"listeners.port", "listeners.source", "listeners.domains", "listeners.connections",
|
|
"listeners.clients", "listeners.metric",
|
|
"connections.id", "connections.source", "connections.in.port",
|
|
"connections.out.host", "connections.out.port", "connections.out.name",
|
|
"connections.out.kind", "connections.out.selectors", "connections.out.address",
|
|
# Phase 32 task 04: the provenance a counted line prints and the dash refuses. `resolved` is what
|
|
# the filter matches an IP against, `via` + `resolved_ts` are the tooltip's `address from libc,
|
|
# resolved 3m` — so a rename of any of the three fails by name here, not as a permanent silence.
|
|
"connections.out.resolved", "connections.out.via", "connections.out.resolved_ts",
|
|
# Phase 33 task 06: the config's own word for a `#off` marker — the page's existence pair
|
|
# reads it (`enabled !== false`), so a rename here paints a disabled route green by silence.
|
|
"connections.enabled",
|
|
"connections.connections", "connections.shared", "connections.metric",
|
|
"warnings.id", "warnings.kind", "warnings.subject", "warnings.message",
|
|
)
|
|
|
|
#: The panels, in the order the page ships them: the two config files, keyed on the payload's own
|
|
#: `source` words, nginx first (the operator named it first, and it carries this fleet's widest
|
|
#: fan-out). `map` and `unmatched` are gone (phase 30 task 02) — a stream `map` is nginx config, so
|
|
#: its routes are that panel's `:443` leaves, and there is no residual panel because nothing outside
|
|
#: the two files is reported at all.
|
|
GATEWAY_CARD_KEYS = ("nginx", "haproxy")
|
|
|
|
|
|
def _payload_field(node: Any, path: str) -> tuple[bool, list[Any]]:
|
|
"""`(found, values)` for one dotted path, where a segment naming a list maps over it.
|
|
|
|
`found` is False only when a **key** is missing, which is the rename this checks for. A null
|
|
value is a found key: the arms that did not measure publish nulls on purpose (rule 2), and the
|
|
page's job is to render them as `n/a`, which the arms test below pins separately.
|
|
"""
|
|
segments = path.split(".")
|
|
if not segments[0]:
|
|
return False, []
|
|
head, rest = segments[0], segments[1:]
|
|
if isinstance(node, list):
|
|
values: list[Any] = []
|
|
for item in node:
|
|
found, mapped = _payload_field(item, path)
|
|
if not found:
|
|
return False, []
|
|
values.extend(mapped)
|
|
return True, values
|
|
if isinstance(node, dict):
|
|
if head not in node:
|
|
return False, []
|
|
if not rest:
|
|
return True, [node[head]]
|
|
return _payload_field(node[head], ".".join(rest))
|
|
return False, []
|
|
|
|
|
|
def one_real_warning() -> list[dict[str, str]]:
|
|
"""One warning row written by `sources/gateway.warnings()`, not by this file.
|
|
|
|
The page renders `warnings` verbatim, so a fixture has to carry the words the source actually
|
|
produces — hand-written prose here would pass a page test while the real sentence changed shape.
|
|
The section is the smallest one that trips `haproxy_route_dangling`: a frontend routing to a
|
|
backend no `backend` block declares, which is the kind this fleet's capture provably does **not**
|
|
have (pinned in `tests/test_gateway_source.py`).
|
|
"""
|
|
broken = {"available": True,
|
|
"frontends": [{"name": "ssh", "default_backend": "gitea-2222",
|
|
"backends": ["gitea-2222"],
|
|
"binds": [{"addr": "::", "port": 22}]}],
|
|
"backends": []}
|
|
found = gateway_source.warnings(broken, None)
|
|
assert len(found) == 1 and found[0]["kind"] == "haproxy_route_dangling", found
|
|
return found
|
|
|
|
|
|
def _warnings_section(html: str) -> str:
|
|
"""The warnings panel's own markup, from its opening tag to its `</section>`."""
|
|
start = html.index('id="gate-warnings"')
|
|
head = html.rindex("<", 0, start)
|
|
return html[head:html.index("</section>", start) + len("</section>")]
|
|
|
|
|
|
def test_the_gateway_page_is_exactly_two_connection_panels_and_a_warnings_list(client) -> None:
|
|
"""The page's shape, on the bytes it serves: two panels, one warnings list, four tallies.
|
|
|
|
Exactly two `[data-gate]` articles — `nginx` then `haproxy`, **ordered**, because the operator
|
|
named nginx first and it carries this fleet's widest fan-out — each shipping the `gate-map` its
|
|
trunk→leaf groups are injected into, its `empty` sentence and its chart disclosure. No `stream
|
|
map` panel and no `unmatched` panel: the `map` is nginx config, so its seventeen entries are that
|
|
panel's `:443` leaves, and a socket neither file describes is reported nowhere (phase 30 task
|
|
02). Above the panels sits `#gate-warnings`, which ships `hidden` because a clean ingress has
|
|
nothing to list and a permanent empty "Warnings to address" is the ink the empty-state rules
|
|
exist to avoid.
|
|
|
|
The four tallies are what the two configs **declare** (`ports`, `connections`), the one number
|
|
the kernel adds (`live`, the open sockets on a port a config names) and `warnings`. The socket
|
|
table's own size is not a cell any more: it is not reconcilable with the map, and a tally an
|
|
operator cannot account for is one they learn to skip. There is also no `#range` control, and
|
|
that is a decision, not an omission: the history here is a count trend and one window for every
|
|
panel is the honest default for a source that re-reads every 30 seconds.
|
|
"""
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
keys = re.findall(r'data-gate="([^"]+)"', html)
|
|
assert keys == list(GATEWAY_CARD_KEYS), (
|
|
f"/gateway ships {keys}; the page is exactly the two config panels, nginx first")
|
|
assert 'data-gate="map"' not in html, "the stream map grew a panel of its own back"
|
|
assert 'data-gate="unmatched"' not in html, "the residual grew a panel back"
|
|
for key in GATEWAY_CARD_KEYS:
|
|
card = html[html.index(f'data-gate="{key}"'):]
|
|
card = card[:card.index("</article>")]
|
|
assert 'class="gate-map" data-k="map"' in card, f"the {key} panel has no group list"
|
|
assert 'data-k="empty"' in card, f"the {key} panel has no empty sentence"
|
|
assert 'data-chart="conns"' in card, f"the {key} panel has no count plot"
|
|
assert 'data-legend="conns"' in card, f"the {key} panel's plot has no legend"
|
|
|
|
panel = _warnings_section(html)
|
|
assert "hidden" in panel[:panel.index(">")], (
|
|
"the warnings panel ships visible, so a clean ingress renders an empty card")
|
|
assert '<ul class="alerts" id="warnings-list">' in panel, (
|
|
"the warnings rows do not use the overview's `ul.alerts` vocabulary")
|
|
assert 'id="warnings-count"' in panel
|
|
|
|
for tally in ("t-ports", "t-connections", "t-live", "t-warnings"):
|
|
assert f'id="{tally}"' in html, f"/gateway lost the {tally} tally"
|
|
for gone in ("t-listeners", "t-established", "t-inbound", "t-outbound", "t-unmatched"):
|
|
assert f'id="{gone}"' not in html, f"/gateway grew the {gone} cell back"
|
|
assert 'id="range"' not in html, "/gateway grew window chips over a gauge that has no rate"
|
|
assert 'src="/static/page-gateway.js"' in client.get(GATEWAY_PAGE).text
|
|
|
|
|
|
def test_the_gateway_page_prints_every_field_the_projection_publishes(
|
|
gateway_client) -> None:
|
|
"""Every label the page builds is a key the payload really sends, resolved over HTTP.
|
|
|
|
This is the rename catcher the slot grid and the `/certs` table already have, and the gateway
|
|
page needs it more than either: a row is joined out of four structures (the trunk's port, the
|
|
endpoint's `host`/`port`, the config's `selectors`, the join's `connections`), so a rename in one
|
|
of them would otherwise draw a plausible line with the wrong target on it — worse than `n/a`,
|
|
because nothing about the picture would look broken.
|
|
|
|
The `warnings.*` paths are resolved twice, over a clean document and over one carrying a real
|
|
row from `sources/gateway.warnings()`: a path into an **empty** list is trivially present, so a
|
|
single pass over this fleet's clean configs could never catch `subject` becoming `name`.
|
|
"""
|
|
payload = gateway_client.get("/api/gateway/snapshot").json()
|
|
assert payload["supported"] is True and payload["host"] == "gateway"
|
|
for path in GATEWAY_PAGE_FIELDS:
|
|
found, _values = _payload_field(payload, path)
|
|
assert found, f"/gateway indexes {path}, which the payload does not publish"
|
|
|
|
broken = gateway_client.get("/api/gateway/snapshot").json()
|
|
broken["warnings"] = one_real_warning()
|
|
for path in GATEWAY_PAGE_FIELDS:
|
|
found, values = _payload_field(broken, path)
|
|
assert found, f"/gateway indexes {path}, which a warning row does not carry"
|
|
if path.startswith("warnings."):
|
|
assert values and all(value for value in values), (
|
|
f"{path} is empty on the row the source writes — the page would print nothing for it")
|
|
|
|
# The two families the panels are keyed on are the payload's own `source` words, and the panel
|
|
# table would silently lose a family if `sources/gateway.py` ever renamed one. Both sides of the
|
|
# map use the same words, so a drift between them is caught here too.
|
|
sources = {row["source"] for row in payload["listeners"]}
|
|
assert {row["source"] for row in payload["connections"]} <= sources, (
|
|
"a route is filed under a config family no trunk of this page carries")
|
|
assert sources == set(GATEWAY_CARD_KEYS), \
|
|
f"the payload's `source` words are {sorted(sources)} and the page ships {GATEWAY_CARD_KEYS}"
|
|
|
|
|
|
def test_every_pipe_is_an_accessible_image_built_from_the_payload(client) -> None:
|
|
"""Each connection line is `role="img"` + an `aria-label` naming its port, target and count.
|
|
|
|
The battery glyph's convention (`DashCharts.battery`), for the same reason: the line carries
|
|
information the markup does not say in words, and here it is worse — a line is a *line*, so
|
|
without a name a screen reader gets a panel that says `nginx` and nothing about the seventeen
|
|
connections on it. Built with `createElementNS` + `setAttribute`, never `innerHTML`, because the
|
|
domains, client addresses and `server_name`s in that sentence are upstream strings.
|
|
|
|
The trunk's facts (live count, client addresses, domain count) are text rather than an image,
|
|
so they are pinned as the renderer that assigns them: the same four payload fields have to reach
|
|
the page whichever component draws them.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert "createElementNS(SVG_NS" in js, "the line is no longer hand-rolled SVG"
|
|
assert "http://www.w3.org/2000/svg" in js
|
|
pipe = js[js.index("function buildPipe("):js.index("function leafRow(")]
|
|
assert "createElementNS" in pipe and "'path'" in pipe
|
|
rendered = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
|
assert "svg.setAttribute('role', 'img')" in rendered
|
|
assert "aria-label" in rendered and "leafAria(" in rendered
|
|
aria = js[js.index("function leafAria("):js.index("function buildPipe(")]
|
|
for fact in ("conn.in", "endpointText(out)", "out.selectors", "conn.connections"):
|
|
assert fact in aria, f"the line's accessible name stopped saying {fact}"
|
|
assert "conn.shared === true" in aria, (
|
|
"a line sharing its endpoint with another route must say so out loud, or the two numbers "
|
|
"read as twice the traffic")
|
|
assert "connections not attributed" in aria, \
|
|
"a line whose join could not count its target must not be announced as a zero"
|
|
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
|
for fact in ("listener.connections", "listener.clients", "listener.domains"):
|
|
assert fact in trunk, f"the trunk stopped saying {fact}"
|
|
# Phase 31 task 03: the trunk now draws a line as well as printing words, so it is an image with
|
|
# a name of its own. **Two** pipe builders' outputs must carry one shape, because the moment one
|
|
# of the two lines loses its `role` or its sentence it is an unnamed stroke beside a named one.
|
|
assert js.count("createElementNS(SVG_NS, 'svg')") == 1, \
|
|
"a second place now constructs an `<svg>`, so the leaf's run and the trunk's stub can "\
|
|
"drift apart in geometry, class shape or accessibility"
|
|
assert "buildPipe(true)" in js and "li.appendChild(buildPipe())" in js, \
|
|
"one of the two lines is no longer built by the one builder"
|
|
stub = js[js.index("function buildPipe("):js.index("function routeInConfig(")]
|
|
assert "'pipe pipe-trunk' : 'pipe'" in stub, \
|
|
"the trunk's stub no longer shares `.pipe` with the leaf's run, so its states are its own"
|
|
assert "trunkAria(" in trunk, "the trunk's line has no accessible sentence of its own"
|
|
assert "svg.setAttribute('role', 'img')" in trunk, \
|
|
"the trunk's line is an unnamed shape: a screen reader gets the panel and none of its ports"
|
|
# Phase 31 task 05 measured this in a browser: `title` is a reflected IDL attribute of
|
|
# `HTMLElement` and **not** of `SVGElement`, so `svg.title = …` invents a JavaScript property no
|
|
# tooltip ever reads. The trunk's sentence has to go on as an attribute or the port's number is
|
|
# ink with no explanation on hover — which is what the served code had been doing.
|
|
assert "svg.setAttribute('title', aria)" in trunk, \
|
|
"the trunk's tooltip is no longer a real attribute: on an `<svg>` an assignment to `.title` " \
|
|
"is a property no hover reads"
|
|
assert "svg.title =" not in trunk, \
|
|
"`svg.title = …` looks like a tooltip and is not one on an SVGElement"
|
|
# upstream text reaches the page through textContent and title, and nothing else …
|
|
assert "innerHTML" not in rendered
|
|
assert "textContent" in js and "escapeHtml" not in js, \
|
|
"the page hand-escapes strings it only ever assigns through textContent"
|
|
# … and the markup the page does template with `innerHTML` is static: no payload string, ever
|
|
for template in re.findall(r"innerHTML = `([^`]*)`", js):
|
|
assert "${" not in template, f"a template interpolates upstream text: {template!r}"
|
|
|
|
|
|
def test_a_pipe_moves_only_while_it_carries_a_connection(client) -> None:
|
|
"""Motion is the connection; colour is the config. Two class pairs, and neither borrows the other.
|
|
|
|
Phase 30 task 05 split what had been one `live`/`still` claim into two, because the operator's
|
|
green means *the route is in the config* and a resting route is still in the config:
|
|
|
|
* `live` / `still` — "a socket is open on this pipe this instant" — decides **motion only**. The
|
|
`animation` lives on the live rule and nowhere else, the static rule declares none, and the
|
|
live rule declares no `stroke`: a busy line and a quiet one are the same colour, or motion has
|
|
become a verdict.
|
|
* `on` / `off` — "a proxy config declares this route" — decides **stroke only**
|
|
(`test_a_connection_is_green_because_it_is_in_the_config`).
|
|
|
|
Phase 31 task 03 added a **second** pipe — the trunk's short stub — and with it a second source of
|
|
truth, which is the part worth pinning. A leaf reads `connections[].connections` (the sockets at
|
|
that route's endpoint); a trunk reads `listeners[].connections`, which `/proc` states as the
|
|
ESTABLISHED sockets whose **local** end is that listen port. The trunk's number is therefore a
|
|
claim about a **port** and **never about a leaf**: `:4443` moving does not mean `nextcloud` has
|
|
traffic, and that is why the trunk's sentence names the port and no endpoint
|
|
(`test_the_trunk_pipe_is_an_accessible_image_of_its_port`). Both pipes use the same two class pairs
|
|
and neither may borrow the other's colour, motion or accessible name.
|
|
|
|
The keyframe's only property stays `stroke-dashoffset`: the dash travelling is the whole
|
|
animation, and animating a colour would put a number's judgement inside a line. And
|
|
`vector-effect="non-scaling-stroke"` stays in the script: the pipe is stretched to the card's
|
|
width, so without it a wide card would draw a thick pipe.
|
|
"""
|
|
section = _code(_css_section(_css(client), "gateway pipes"))
|
|
live = _declarations(section, ".pipe.live .pipe-path")
|
|
assert live.get("animation", "").startswith("pipe-flow"), \
|
|
"the animated state no longer runs the flow animation"
|
|
assert live.get("stroke") is None, (
|
|
"the live rule paints again, so a resting route no longer looks like the configured route "
|
|
"it is — colour belongs to `on`/`off` and motion belongs to `live`/`still`")
|
|
assert _declarations(section, ".pipe-path").get("animation") is None, \
|
|
"motion moved onto the base rule: every pipe animates, resting ones included"
|
|
still = _declarations(section, ".pipe.still .pipe-path")
|
|
assert still.get("animation") is None, "the static state animates — zero is not traffic"
|
|
assert still.get("stroke-dasharray"), "the static pipe lost its dotted rest state"
|
|
assert still.get("stroke") is None, \
|
|
"`still` colours itself again: a resting route must keep the config's green"
|
|
keyframes = re.search(r"@keyframes\s+pipe-flow\s*\{(.+?)\n\}", section, re.S)
|
|
assert keyframes, "the flow animation has no keyframes to run"
|
|
body = keyframes.group(1)
|
|
assert body.count("stroke-dashoffset") == 2 and "stroke:" not in body, \
|
|
"the keyframe must travel the dash and nothing else — animating colour is a verdict"
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert "svg.classList.toggle('live', live)" in js
|
|
assert "svg.classList.toggle('still', !live)" in js
|
|
assert "conn.connections > 0" in js, \
|
|
"the live test is no longer the connection count itself"
|
|
assert 'vector-effect' in js
|
|
|
|
# …and the trunk's stub, whose `live` comes from a **different payload path** and may only ever
|
|
# come from that one. A leaf answers "is a socket open on this route?"; a trunk answers "is a
|
|
# socket arriving on this port?" — the port's number belongs to the trunk as a whole, so crediting
|
|
# it to one of the leaves under it would be a claim `listeners[]` cannot make.
|
|
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
|
assert "const count = listener.connections" in trunk, \
|
|
"the trunk no longer reads its own `listeners[].connections` field"
|
|
assert "const measured = typeof count === 'number' && isFinite(count)" in trunk, \
|
|
"the trunk no longer separates `0` (measured, idle) from `null` (not measured)"
|
|
assert "svg.classList.toggle('live', measured && count > 0)" in trunk, (
|
|
"the trunk's motion is no longer the port's own count — either it animates on an unknown "
|
|
"(a `null` read as traffic) or it stopped animating on a busy port at all")
|
|
assert "svg.classList.toggle('still', !measured || count === 0)" in trunk, \
|
|
"the trunk has no static state, so an unknown reads as motion"
|
|
for leaf_field in ("conn.connections", "endpointText", "out.selectors", "connections.shared"):
|
|
assert leaf_field not in trunk, (
|
|
f"the trunk reads {leaf_field} — a trunk's line is a fact about a **port** and may never "
|
|
"be derived from a leaf's endpoint, or one stub would move for one route out of sixteen")
|
|
assert "const live = typeof conn.connections === 'number'" in js, \
|
|
"the leaf's motion no longer keys on its own endpoint count"
|
|
|
|
|
|
def test_a_connection_is_green_because_it_is_in_the_config(client) -> None:
|
|
"""`--gate-on` is the whole colour story, and it answers *does the config say so*.
|
|
|
|
The operator's words were "green if the connection is active", with active defined in the phase
|
|
as *written in the nginx / haproxy config*. That is an **existence** state, so it gets its own
|
|
pair of tokens (`--gate-on` / `--gate-off`, declared once in `:root` as a substitution of the
|
|
verdict colours so both themes follow) and it never borrows `--ok` — a route existing is not
|
|
good news, and a colour the operator reads as a judgement on a number is exactly what
|
|
`severity.py` refuses to publish for a `gateway.*` metric.
|
|
|
|
Four things make the pair mean something rather than merely exist, and each is one of the ways
|
|
this could silently rot: the base `.pipe-path` rule declares **no** `stroke`, so colour can only
|
|
arrive through a state class; `.pipe.on` and `.pipe.off` are the two classes that carry it; the
|
|
script puts `on` on a connection only while the config still spells it (`enabled !== false`
|
|
— the `#off` marker is the out-of-config state the pair was declared for, phase 33 tasks
|
|
04/06); and the `off` half is declared, which is the only reason the pair is a pair.
|
|
"""
|
|
css = _css(client)
|
|
code = _code(css)
|
|
light_at = code.index('[data-theme="light"]')
|
|
root = code[code.index(":root {"):light_at]
|
|
assert re.search(r"--gate-on:\s*var\(--ok\)", root), (
|
|
"`--gate-on` stopped being the one indirection over the theme colours — a literal here "
|
|
"would not follow `[data-theme=\"light\"]` and the page would need a second declaration")
|
|
assert re.search(r"--gate-off:\s*var\(--fail\)", root), "`--gate-off` is missing from `:root`"
|
|
assert code.count("--gate-on:") == 1 and code.count("--gate-off:") == 1, \
|
|
"the existence pair is declared more than once, which is how a pair drifts from its theme"
|
|
|
|
section = _code(_css_section(css, "gateway pipes"))
|
|
assert _declarations(section, ".pipe-path").get("stroke") is None, \
|
|
"the base rule paints, so a pipe has a colour the state classes did not grant it"
|
|
assert _declarations(section, ".pipe.on .pipe-path").get("stroke") == "var(--gate-on)", \
|
|
"the in-the-config state stopped taking its green from the existence pair"
|
|
assert _declarations(section, ".pipe.off .pipe-path").get("stroke") == "var(--gate-off)", \
|
|
"the out-of-the-config state has no colour, so the pair is half a vocabulary"
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert "svg.classList.toggle('on', on)" in js and "svg.classList.toggle('off', !on)" in js, \
|
|
"the existence classes are not both driven from one value, so a row can contradict itself"
|
|
exists = js[js.index("function routeInConfig("):js.index("function buildSwitch(")]
|
|
assert "conn.enabled !== false" in exists, (
|
|
"a drawn connection is no longer judged by the config's own word — the `#off` marker is "
|
|
"the out-of-config state the existence pair exists for, and an unconditional `true` here "
|
|
"would paint a disabled route green")
|
|
assert "const on = routeInConfig(conn)" in js
|
|
|
|
|
|
def test_the_whole_route_takes_one_existence_class(client) -> None:
|
|
"""One class on the `<li>` paints the spine, the tick, the run **and** the switch.
|
|
|
|
The operator's complaint was about ink they could see: *"Why are the dotted lines on the left
|
|
grey? Shouldn't those be green too? They're active."* Phase 30 applied the existence pair to
|
|
`.pipe-path` alone, so every route drew its last third in the config's green and the rest of its
|
|
length in structural grey — a picture that contradicted the page's own claim on all 33 rows.
|
|
Phase 31 task 01 moves the state onto the **row**: `renderLeaf` toggles `on`/`off` on the `<li>`
|
|
off the same value it colours the `<svg>` with, and four rules reach the two halves of the spine
|
|
and the tick out to the switch from that one class.
|
|
|
|
Two rules keep that from being a colour change with no contract behind it:
|
|
|
|
* The base connector rules declare `transparent`. An SVG path with no `stroke` is invisible for
|
|
free; a border's default colour is `currentColor`, so "a row built and never rendered draws
|
|
nothing" has to be **written down** for a border the way the missing `stroke` does for a path.
|
|
* The switch keeps taking its colour from `[aria-checked]` (phase 30's pin, asserted again here).
|
|
The row is now painted by two selectors — `.gate-link.on` and `[aria-checked]` — both driven
|
|
from one value, and a pin on only one of them is how the two halves of a row end up disagreeing.
|
|
"""
|
|
section = _code(_css_section(_css(client), "gateway pipes"))
|
|
rules = [(" ".join(sel.split()), body)
|
|
for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", section)]
|
|
|
|
def painted(selector: str) -> list[str]:
|
|
"""Bodies of the rules whose selector **list** names `selector` exactly."""
|
|
return [body for sel, body in rules
|
|
if selector in [part.strip() for part in sel.split(",")]]
|
|
|
|
for segment in (".gate-link.on::before", ".gate-link.on::after", ".gate-link.on .gate-elbow"):
|
|
bodies = painted(segment)
|
|
assert bodies, f"nothing paints {segment} from the row's `on` class — that segment of the " \
|
|
"route is still structural ink, which is the defect this task was written to close"
|
|
assert "var(--gate-on)" in " ".join(bodies), \
|
|
f"{segment} takes its colour from somewhere other than the existence pair"
|
|
for segment in (".gate-link.off::before", ".gate-link.off::after", ".gate-link.off .gate-elbow"):
|
|
bodies = painted(segment)
|
|
assert bodies, f"nothing paints {segment} from the row's `off` class, so the pair is half a " \
|
|
"vocabulary for the connectors and a route could never be drawn out of the config"
|
|
assert "var(--gate-off)" in " ".join(bodies), \
|
|
f"{segment} takes its colour from somewhere other than the existence pair"
|
|
|
|
# Geometry, no colour: a row the payload pass never reached must draw no dashed ink at all.
|
|
for selector, prop in ((".gate-link::before", "border-left"),
|
|
(".gate-link::after", "border-left"),
|
|
(".gate-elbow", "border-top")):
|
|
value = _declarations(section, selector).get(prop, "")
|
|
assert value.endswith("transparent"), (
|
|
f"{selector} declares {value!r} instead of a dashed border whose colour is "
|
|
"`transparent` — an uncoloured border is `currentColor`, so an unrendered row would draw "
|
|
"a line in whatever colour its text happens to be")
|
|
assert "--border-strong" not in section, (
|
|
"a gateway connector still reads the structure token: the row's green has to run from its "
|
|
"trunk to its endpoint, and one grey segment of it says the route is not in the config")
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
leaf = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
|
assert "const on = routeInConfig(conn)" in leaf, \
|
|
"the row's state no longer comes from the one reading of the payload"
|
|
for target in ("li", "svg"):
|
|
assert f"{target}.classList.toggle('on', on)" in leaf, \
|
|
f"`on` is no longer written on the {target}, so part of the route colours itself "\
|
|
"without the payload saying the route exists"
|
|
assert f"{target}.classList.toggle('off', !on)" in leaf, \
|
|
f"`off` is not written on the {target} beside `on` — one row could show both states"
|
|
assert leaf.index("const on = routeInConfig(conn)") < leaf.index(
|
|
f"{target}.classList.toggle('on', on)"), (
|
|
f"the {target}'s state is set before the payload is read")
|
|
row = js[js.index("function leafRow("):js.index("function renderLeaf(")]
|
|
assert "li.className = 'gate-link'" in row
|
|
assert "classList" not in row, \
|
|
"a row now ships a state class before any payload reached it, so an unrendered leaf draws a "\
|
|
"confident route"
|
|
|
|
# …and the two halves of the row that already had the pair still take it the same way, so the
|
|
# connectors joining them cannot arrive on a third vocabulary.
|
|
assert _declarations(section, ".pipe.on .pipe-path").get("stroke") == "var(--gate-on)", \
|
|
"the run no longer colours from the pair the connectors do"
|
|
assert "var(--gate-on)" in _declarations(section, ".gate-switch").get("border", ""), \
|
|
"the switch's on-state border is no longer the existence pair"
|
|
assert "var(--gate-off)" in _declarations(
|
|
section, '.gate-switch[aria-checked="false"]').get("border-color", ""), \
|
|
"the switch's off state has no colour, so `[aria-checked]` is decoration again"
|
|
|
|
|
|
def rendered_trunk(row: dict[str, Any]) -> dict[str, Any]:
|
|
"""What one trunk prints, claims and animates, recomputed from the payload as a **second** reading.
|
|
|
|
The `rendered_leaf` idiom, for the same reason: there is no JS runner (a locked anchor), so the
|
|
sentence and the two classes are re-derived here from the literals the served-byte pins below
|
|
assert verbatim. `renderTrunk` prints `N live` / `n/a` in `.pipe-count` and `trunkAria` says the
|
|
port's sentence; a rename or a paraphrase on either side therefore fails a pin **and** moves what
|
|
this mirror prints. Nothing here reads a leaf: the trunk's whole claim is about its port.
|
|
"""
|
|
port = f":{row['port']}"
|
|
count = row["connections"]
|
|
measured = isinstance(count, (int, float)) and not isinstance(count, bool)
|
|
if not measured:
|
|
aria = f"{port} \u2014 connections on this port are unknown: the socket table did not answer"
|
|
else:
|
|
how_many = "no" if count == 0 else str(count)
|
|
verb = "connection is" if count == 1 else "connections are"
|
|
aria = f"{port} \u2014 {how_many} {verb} open on this port right now"
|
|
return {"count": "n/a" if not measured else f"{count} live",
|
|
"aria": aria,
|
|
"live": bool(measured and count > 0),
|
|
"still": not measured or count == 0,
|
|
# a trunk row exists because a config names the port, which is the existence fact
|
|
"on": True}
|
|
|
|
|
|
def test_the_trunk_pipe_is_an_accessible_image_of_its_port(client, gateway_client,
|
|
gateway_snapshot) -> None:
|
|
"""Every trunk draws a short line that moves on **its port's** count and says so in one sentence.
|
|
|
|
The operator's complaint (2026-10-23): *"The only animated line is `:443 -> 127.0.0.1:4443`."*
|
|
On the live ingress almost every leaf endpoint is a **name** the page cannot resolve, so its count
|
|
is a dash and its line is still — while the kernel states, for the port itself, how many
|
|
ESTABLISHED sockets have it as their local end. That figure is a real measurement, and it is the
|
|
only live number this page can print until name resolution lands, so the trunk draws it as a line.
|
|
|
|
The claim has to stay a **port** claim. `:4443` moving does not mean `nextcloud`'s route carries
|
|
those sockets, so the sentence names the port and no endpoint, in all three arms:
|
|
|
|
:4443 \u2014 9 connections are open on this port right now
|
|
:4443 \u2014 no connections are open on this port right now
|
|
:4443 \u2014 connections on this port are unknown: the socket table did not answer
|
|
|
|
and the unknown arm never prints `0` — neither in the sentence nor in `.pipe-count` (rule 2). The
|
|
three blocks below are the three ways that goes wrong: the pipe stops being a named image; the
|
|
sentence paraphrases "on this port" away or names an endpoint; or the unknown arm is treated as
|
|
zero and a silent router starts looking like an idle one.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
|
|
# (1) the stub is built by the one pipe builder and lands inside the trunk, after the count.
|
|
group = js[js.index("function groupElement("):js.index("function trunkAria(")]
|
|
assert "wrap.querySelector('.gate-trunk').appendChild(buildPipe(true))" in group, (
|
|
"the trunk no longer draws a line, or draws it somewhere other than the trunk itself \u2014 "
|
|
"an `<svg>` cannot come from the `innerHTML` template, which is why it is appended")
|
|
assert group.index("pipe-count") < group.index("buildPipe(true)"), \
|
|
"the stub is no longer the count's neighbour at the trunk's right edge"
|
|
|
|
# (2) the sentence: three arms, every word pinned, and nothing but `listeners[].connections`.
|
|
aria = js[js.index("function trunkAria("):js.index("function renderTrunk(")]
|
|
assert "`${port} \u2014 connections on this port are unknown: the socket table did not answer`" in aria, \
|
|
"the unknown arm no longer says `unknown` \u2014 it must not read as an idle port (rule 2)"
|
|
assert "open on this port right now" in aria, \
|
|
"the measured arms stopped naming the port as the subject of the sentence"
|
|
assert "'no'" in aria and "'connection is'" in aria and "'connections are'" in aria, \
|
|
"the measured arms stopped saying 0 as `no` and 1 as `connection is`"
|
|
assert "(group.listener || {}).connections" in aria, \
|
|
"the sentence no longer reads the port's own figure"
|
|
for leaf_field in ("conn.", "out.", "endpointText", "selectors", "host", "clients"):
|
|
assert leaf_field not in aria, (
|
|
f"the trunk's sentence reaches for {leaf_field} \u2014 the sockets a port counts belong to "
|
|
"the trunk as a whole, and naming a route would credit them to one leaf")
|
|
|
|
# (3) the classes, and the `null` arm that must not move and must not print `0`.
|
|
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
|
assert "svg.hidden = group.listener == null" in trunk, \
|
|
"a group with no listener row now draws a stub for a port nobody read"
|
|
assert "svg.classList.toggle('on', group.listener != null)" in trunk, \
|
|
"the stub stopped taking its colour from the existence fact the trunk row itself is"
|
|
assert "svg.classList.toggle('off', false)" in trunk, \
|
|
"`off` is no longer cleared on the trunk, so a re-used group could keep a stale state"
|
|
|
|
# (4) the fixture render, both directions of the rule, over the captured ingress and over the
|
|
# socket table going quiet (`sockets_silent`, which is what the agent's unreadable
|
|
# /proc/net/tcp{,6} actually produces).
|
|
answered = gateway_client.get("/api/gateway/snapshot").json()
|
|
listeners = answered["listeners"]
|
|
assert listeners, "the capture names no port, so this pin would prove nothing"
|
|
endpoints = {f"{c['out']['host']}:{c['out']['port']}" for c in answered["connections"]}
|
|
busy = [row for row in listeners if row["connections"]]
|
|
idle = [row for row in listeners if row["connections"] == 0]
|
|
assert busy and idle, (
|
|
f"the capture reports {sorted(r['port'] for r in busy)} busy and "
|
|
f"{sorted(r['port'] for r in idle)} idle \u2014 both arms need a port in them to be pinned")
|
|
for row in listeners:
|
|
printed = rendered_trunk(row)
|
|
assert printed["live"] == bool(row["connections"]), \
|
|
f":{row['port']} animates on something other than its own non-zero count"
|
|
assert printed["live"] != printed["still"], \
|
|
f":{row['port']} is both moving and still \u2014 the two arms are one class each"
|
|
assert printed["aria"].startswith(f":{row['port']} \u2014"), \
|
|
"the sentence no longer leads with the port it is about"
|
|
for endpoint in endpoints:
|
|
assert endpoint not in printed["aria"], (
|
|
f"the trunk of :{row['port']} names the endpoint {endpoint} \u2014 a port's count is "
|
|
"not that route's traffic")
|
|
# both directions of "never `0` for an unknown"
|
|
assert (printed["count"] == "n/a") == (row["connections"] is None), \
|
|
f":{row['port']} prints {printed['count']!r} for a count of {row['connections']!r}"
|
|
# …and the other half of the same honesty: a port the table *did* answer with nothing on it
|
|
# says so. `0` is a measurement here, not an absence, and it is the reason the trunk prints
|
|
# `n/a` for the unknown instead of one word for both facts.
|
|
if row["connections"] is not None:
|
|
assert printed["count"] == f"{row['connections']} live", \
|
|
f":{row['port']} hid a count the socket table actually reported"
|
|
assert rendered_trunk(busy[0])["live"], "a port the kernel reports traffic on sits still"
|
|
assert not rendered_trunk(idle[0])["live"], "an idle port animates \u2014 motion became a guess"
|
|
|
|
silent = gateway_source.parse(gateway_snapshot("sockets_silent"), "gateway")
|
|
quiet = silent["listeners"]
|
|
assert quiet and all(row["connections"] is None for row in quiet), \
|
|
("`sockets_silent` no longer yields null listener counts, so this no longer exercises the "
|
|
"arm the page has to render as `n/a`")
|
|
for row in quiet:
|
|
printed = rendered_trunk(row)
|
|
assert printed["on"] and not printed["live"] and printed["still"], (
|
|
f":{row['port']} is not green-and-motionless with the socket table quiet \u2014 the "
|
|
"configs are still drawn and nothing is claimed about traffic")
|
|
assert printed["count"] == "n/a", \
|
|
f":{row['port']} prints {printed['count']!r} instead of `n/a` when nobody measured"
|
|
assert printed["count"] != "0 live", "an unanswered socket table became a claimed zero"
|
|
assert "unknown" in printed["aria"] and "did not answer" in printed["aria"], \
|
|
f":{row['port']}'s sentence hides that the reading is missing"
|
|
assert not any(rendered_trunk(row)["count"] == "0 live" for row in quiet), \
|
|
"a page with no socket table printed `0 live` somewhere"
|
|
|
|
|
|
def _leaf_haystack(row: dict[str, Any]) -> str:
|
|
"""The one string the filter can see about one route, as `page-gateway.js` composes it.
|
|
|
|
Phase 32 task 04 adds the **sixth** field: `out.resolved`, the addresses the gateway published for
|
|
a name target. Typing an IP is how an operator asks "which route owns the socket I am looking at",
|
|
and a haystack of names alone cannot answer it.
|
|
"""
|
|
out = row.get("out") or {}
|
|
port = (row.get("in") or {}).get("port")
|
|
parts = ["" if port is None else str(port), out.get("host"), out.get("name"), out.get("kind"),
|
|
*(out.get("selectors") or []), *(out.get("resolved") or [])]
|
|
return " ".join(p for p in parts if isinstance(p, str) and p).lower()
|
|
|
|
|
|
def _trunk_haystack(port: int, listener: dict[str, Any]) -> str:
|
|
"""The same for a trunk: the port as the page prints it, then its domains and clients."""
|
|
parts = [f":{port}", *(listener.get("domains") or []), *(listener.get("clients") or [])]
|
|
return " ".join(p for p in parts if isinstance(p, str) and p).lower()
|
|
|
|
|
|
def filter_view(snapshot: dict[str, Any], query: str) -> dict[str, Any]:
|
|
"""What the page leaves standing for one query, derived as a **second** reading.
|
|
|
|
There is no JS runner (a locked anchor), so this is `rendered_leaf`'s idiom: the rules are
|
|
re-applied here from the payload, and the served-byte pins in the test below are what keep the
|
|
mirror honest — `haystack` indexes `in.port`, `out.host`, `out.name`, `out.kind`,
|
|
`out.selectors`, `out.resolved`;
|
|
`trunkHaystack` indexes `:${port}`, `domains`, `clients`; a trunk that matches keeps **all** its
|
|
leaves; a group stands when it or one of its routes answers. A rename on either side therefore
|
|
fails a pin *and* changes what this mirror prints.
|
|
|
|
Returns the panels' worth of groups plus the two numbers the count chip prints, both over leaf
|
|
routes: `standing` and `total`.
|
|
"""
|
|
needle = query.strip().lower()
|
|
groups: list[dict[str, Any]] = []
|
|
standing = 0
|
|
for key in ("nginx", "haproxy"): # the order `CARDS` declares
|
|
by_port: dict[int, dict[str, Any]] = {}
|
|
for row in snapshot["listeners"]:
|
|
if row["source"] != key:
|
|
continue
|
|
by_port.setdefault(row["port"], {"panel": key, "port": row["port"],
|
|
"listener": None,
|
|
"connections": []})["listener"] = row
|
|
for row in snapshot["connections"]:
|
|
if row["source"] != key:
|
|
continue
|
|
by_port.setdefault(row["in"]["port"],
|
|
{"panel": key, "port": row["in"]["port"], "listener": None,
|
|
"connections": []})["connections"].append(row)
|
|
|
|
for port in sorted(by_port):
|
|
group = by_port[port]
|
|
trunk_hit = needle in _trunk_haystack(port, group["listener"] or {})
|
|
leaves = []
|
|
for row in group["connections"]:
|
|
shown = trunk_hit or needle in _leaf_haystack(row)
|
|
leaves.append({"id": row["id"], "hidden": not shown})
|
|
if shown:
|
|
standing += 1
|
|
groups.append({"panel": key, "port": port, "trunk_hit": trunk_hit,
|
|
"hidden": bool(needle) and not trunk_hit
|
|
and not any(not leaf["hidden"] for leaf in leaves),
|
|
"leaves": leaves})
|
|
return {"groups": groups, "standing": standing, "total": len(snapshot["connections"])}
|
|
|
|
|
|
def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_client,
|
|
gateway_snapshot: Any) -> None:
|
|
"""One field, four rules, and not one extra request.
|
|
|
|
The operator's complaint is a reading-speed one: *"I want to find a domain."* This ingress routes
|
|
33 of them off two config files, and a page that draws all 33 every 30 s makes that a scan. The
|
|
field is an **eye, not a query**, and the four halves of that sentence are the four ways the
|
|
feature could go wrong:
|
|
|
|
* **It matches payload text and nothing else.** `in.port`, `out.host`, `out.name`, `out.kind`,
|
|
`out.selectors` and `out.resolved` for a route, `:${port}` + `domains` + `clients` for a trunk.
|
|
The sixth field is phase 32 task 04's: an address the **gateway** published is text the payload
|
|
already carries, so typing the far end of a socket finds the route that owns it. No DNS (the
|
|
page still resolves no name — it matches a string, it never looks one up), no re-ranking, no
|
|
rule about `.reeselink.com` — a query can never invent a route, because there is no other text
|
|
to match.
|
|
* **It costs nothing.** The repaint runs on `state.snapshot`; a keystroke that re-fetched would be
|
|
a poll per character and a lie about what changed.
|
|
* **It hides rows, it does not remove them.** `li.hidden` in the patch pass, so a route the query
|
|
hides keeps its identity, its state classes and its place for the next poll.
|
|
* **It moves nothing else.** The four header tallies are the payload's figures, the warnings panel
|
|
is a different claim, and the charts follow the panel: beside a filtered map the header still
|
|
says what the whole ingress declares and carries.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
|
|
# (1) the matcher's whole vocabulary: six payload fields for a route, three for a trunk.
|
|
hay = js[js.index("function haystack("):js.index("function trunkHaystack(")]
|
|
assert "String((conn.in || {}).port ?? '')" in hay, \
|
|
"the inbound port is no longer searchable — `4443` is the second thing an operator types"
|
|
for field in ("out.host", "out.name", "out.kind", "out.selectors", "out.resolved"):
|
|
assert field in hay, f"the filter no longer sees {field}"
|
|
# Phase 32 task 04: an address the **gateway** resolved is payload text like any other, so typing
|
|
# the far end of a socket finds the route that owns it. Names alone could not answer that question.
|
|
assert "...(out.resolved || [])" in hay, \
|
|
"a name-written route stopped being findable by the address it resolves to"
|
|
assert "toLowerCase()" in hay, "the match became case-sensitive on a page of lowercase domains"
|
|
for absent in ("conn.connections", "severity", "metric", "getaddrinfo", "split(",
|
|
"endsWith", "startsWith", "slice("):
|
|
assert absent not in hay, (
|
|
f"the matcher reached for {absent} — it may read what the payload calls a route, never "
|
|
"what it reports about it, and never resolve a name")
|
|
# …and the three things that would stop it being an eye over the document in hand: a dial, a read
|
|
# of what is painted rather than of what the payload says, and a literal `null` a query could
|
|
# match against a row that published nothing.
|
|
for not_here in ("fetchJSON", "textContent", "innerText", '"null"', "'null'"):
|
|
assert not_here not in hay, f"the haystack reached for {not_here}"
|
|
trunk = js[js.index("function trunkHaystack("):js.index("function filtering(")]
|
|
for field in ("group.port", "listener.domains", "listener.clients"):
|
|
assert field in trunk, f"the trunk no longer answers for {field}"
|
|
|
|
# (2) the repaint path: the payload in hand, no network, and no re-entry into the poll.
|
|
panels = js[js.index("function renderPanels("):js.index("function pruneCards(")]
|
|
assert "fetchJSON" not in panels and "refresh(" not in panels, (
|
|
"the filter path dials something — it is an eye over `state.snapshot`, and a keystroke must "
|
|
"cost no request (nor may it re-enter `refresh()`, which fetches a history pair per panel)")
|
|
wiring = js[js.index("function initFilter("):js.index("function pruneCards(")]
|
|
assert "state.filter = event.target.value.trim()" in wiring, \
|
|
"the filter stopped taking its needle verbatim-and-trimmed from the field"
|
|
assert "event.key !== 'Escape'" in wiring, \
|
|
"Escape no longer clears the field, so getting out of a filter means backspacing it"
|
|
assert "fetchJSON" not in wiring and "refresh(" not in wiring
|
|
count = js[js.index("function renderFilterCount("):js.index("function initFilter(")]
|
|
assert "`${standing} of ${total} routes`" in count, \
|
|
"the chip stopped counting leaf routes the way the panels draw them"
|
|
assert "chip.hidden = true" in count, \
|
|
"the count stays on screen beside an empty field — ink about nothing"
|
|
|
|
# (3) the tallies and the warnings list never move, because neither is a route.
|
|
for name, end in (("renderHeader", "async function refresh("),
|
|
("renderWarnings", "function dotFor(")):
|
|
body = js[js.index(f"function {name}("):js.index(end)]
|
|
for leak in ("state.filter", "filtering(", "haystack(", "leafShown(", "trunkShown("):
|
|
assert leak not in body, (
|
|
f"{name} now reads {leak} — the header's four figures and the warnings count are the "
|
|
"payload's own and must keep saying what the whole ingress declares")
|
|
|
|
# (4) rows are hidden, not removed, inside the patch pass that already holds them.
|
|
leaves = js[js.index("function renderLeaves("):js.index("function renderGroups(")]
|
|
assert "li.hidden = shown !== null && !shown[index]" in leaves, (
|
|
"a leaf is no longer hidden by index into the group's own connection list — the one form "
|
|
"that cannot credit a count to the wrong row")
|
|
refusal = leaves[leaves.index("const emptyId"):]
|
|
assert ".hidden" not in refusal, (
|
|
"the trunk's refusal sentence is being hidden by a route's rules — it belongs to the trunk, "
|
|
"so this fleet's `:80` is found by typing `80` and by nothing a route says")
|
|
assert ".remove()" not in leaves, \
|
|
"the filter deleted a row; removal belongs to a route leaving the config"
|
|
groups = js[js.index("function renderGroups("):js.index("function familyWord(")]
|
|
assert "el.hidden = !visible" in groups, "a trunk is no longer hidden as a whole"
|
|
assert "const shown = trunk ? null : group.connections.map(leafShown)" in groups, (
|
|
"a trunk that answered the query no longer keeps its whole fan-out — typing `4443` asks for "
|
|
"the port, and every route on it is part of the answer")
|
|
assert "const visible = !filtering() || trunk || shown.some(Boolean)" in groups, (
|
|
"a group is no longer standing on \"the trunk or one of its routes\", which is the one rule "
|
|
"that decides whether a port disappears from the map")
|
|
assert "none.hidden = !stranded" in groups and \
|
|
"const stranded = filtering() && groups.length > 0 && standing === 0" in groups, (
|
|
"the panel's no-match sentence is no longer keyed on all three of: the filter is up, there "
|
|
"were groups, and the query hid every one of them")
|
|
assert '[data-k="no-match"]' in groups and 'no route matches' in groups, \
|
|
"a panel whose every group is hidden now says nothing at all"
|
|
|
|
# (5) the capture, through the mirror: the four answers the field has to give.
|
|
payload = gateway_client.get("/api/gateway/snapshot").json()
|
|
total = len(payload["connections"])
|
|
assert total > 10, "the capture routes too little for a filter to prove anything"
|
|
|
|
everything = filter_view(payload, "")
|
|
assert everything["standing"] == total and not any(
|
|
leaf["hidden"] for group in everything["groups"] for leaf in group["leaves"]), \
|
|
"an empty query hides something — it must be the same page as no query at all"
|
|
assert not any(group["hidden"] for group in everything["groups"]), \
|
|
"an empty query hid a trunk"
|
|
|
|
brain = filter_view(payload, "brain")
|
|
shown = [leaf["id"] for group in brain["groups"] for leaf in group["leaves"]
|
|
if not leaf["hidden"]]
|
|
assert brain["standing"] == len(shown) > 1, \
|
|
"`brain` matched nothing but the brain routes"
|
|
assert all("brain" in _leaf_haystack(next(c for c in payload["connections"]
|
|
if c["id"] == leaf_id))
|
|
for leaf_id in shown), "`brain` showed a route it does not appear in"
|
|
assert all(c["id"] in shown for c in payload["connections"]
|
|
if "brain" in _leaf_haystack(c)), "`brain` lost a route it appears in"
|
|
assert any(group["port"] == 443 and not group["hidden"] for group in brain["groups"]), \
|
|
"the trunk the brain routes hang off was hidden with the rest of its fan-out"
|
|
assert brain["standing"] < total, "nothing was filtered out at all"
|
|
|
|
port4443 = filter_view(payload, "4443")
|
|
for group in port4443["groups"]:
|
|
if group["port"] == 4443:
|
|
assert not group["hidden"] and all(
|
|
not leaf["hidden"] for leaf in group["leaves"]), \
|
|
":4443 did not keep its whole fan-out — typing a port asks for the port"
|
|
else:
|
|
assert group["hidden"], f":{group['port']} survived a query for :4443"
|
|
|
|
nothing = filter_view(payload, "no-such-domain-anywhere")
|
|
assert nothing["standing"] == 0 and all(group["hidden"] for group in nothing["groups"]), \
|
|
"a query that matches nothing left a row or a trunk on the page"
|
|
|
|
# (6) a trunk answers for **itself**: a client address is the listener's own payload, and typing
|
|
# it is a question about the port, so every route under it stays as part of the answer.
|
|
client_row = next((row for row in payload["listeners"] if row["clients"]), None)
|
|
assert client_row, "the captured socket table names no client, so this arm cannot be exercised"
|
|
by_port = filter_view(payload, client_row["clients"][0])
|
|
home = next(group for group in by_port["groups"] if group["port"] == client_row["port"])
|
|
assert home["trunk_hit"] and not home["hidden"], \
|
|
f"typing {client_row['clients'][0]!r} did not match :{client_row['port']}'s own row"
|
|
assert all(not leaf["hidden"] for leaf in home["leaves"]), \
|
|
"a trunk that matched lost leaves of its fan-out"
|
|
|
|
# (7) phase 32 task 04: an address the **gateway** resolved is searchable too, which is the field
|
|
# an operator reaches for when they are holding a socket rather than a domain. Over the 1.2.0
|
|
# document there is nothing to match (no resolver section, and the page resolves nothing), so
|
|
# this leg runs on the recorded 1.3.0 one.
|
|
placed = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
|
assert not filter_view(payload, "10.1.2.64")["standing"], \
|
|
"the page found an address it was never given — it resolves nothing, and must keep not doing it"
|
|
brain_ip = filter_view(placed, "10.1.2.64")
|
|
found = [leaf["id"] for group in brain_ip["groups"] for leaf in group["leaves"]
|
|
if not leaf["hidden"]]
|
|
assert found and all("reeseapps-brain-of-reese" in route_id for route_id in found), \
|
|
f"typing the brain's address left {found}"
|
|
assert all(group["hidden"] or group["port"] == 443 for group in brain_ip["groups"]), \
|
|
"an address match let a trunk of another port stand"
|
|
shared_ip = filter_view(placed, "10.1.1.78")
|
|
expected = sum(1 for row in placed["connections"] if "10.1.1.78" in (row["out"]["resolved"] or []))
|
|
assert expected > 1 and shared_ip["standing"] == expected, (
|
|
f"one address reached by {expected} routes was found by {shared_ip['standing']} of them")
|
|
|
|
|
|
def test_the_filter_field_is_markup_the_sheet_and_nobody_else_uses(client) -> None:
|
|
"""The field is pinned markup, on one page, wearing the sheet's one field skin.
|
|
|
|
Three absences carry as much as the two presences. **No other page ships `type="search"`**: the
|
|
shared rule is what makes a field match the theme, and a page growing its own styled box is how a
|
|
control stops following a theme change. The base rule is `max-width: 18rem; min-width: 0` and the
|
|
≤640 px block makes it `width: 100%` — a 33-name ingress is exactly the case for typing into the
|
|
field on a phone, and a field that keeps 18 rem of cap there is a field with two characters of
|
|
visible text. And `.gate-filter` carries no `:hover`, no `cursor` and no `transition`: the
|
|
connection map owns one interactive-looking thing (the unwired switch's knob) and this is not it.
|
|
"""
|
|
css = _css(client)
|
|
shared = _declarations(css, 'input[type="text"], input[type="password"], input[type="search"]')
|
|
for prop in ("background", "color", "border", "border-radius", "padding", "font"):
|
|
assert shared.get(prop), f"the shared field rule no longer declares {prop}"
|
|
assert shared.get("border") == "1px solid var(--border)", \
|
|
"the field skin drifted from the sheet's own border token"
|
|
|
|
section = _code(_css_section(css, "gateway pipes"))
|
|
base = _declarations(section, ".gate-filter")
|
|
assert base.get("max-width") == "18rem", \
|
|
f"the field is capped at {base.get('max-width')!r} rather than 18rem"
|
|
assert base.get("min-width") == "0", \
|
|
"without `min-width: 0` the field cannot shrink, so a long placeholder grows the page head"
|
|
|
|
# The phone override lives in the gateway section's own ≤640 px block and says only that one thing.
|
|
mobile = section[section.index("@media (max-width: 640px)"):]
|
|
reach = re.search(r"\.gate-filter\s*\{([^}]*)\}", mobile)
|
|
assert reach, "the field is not full-width at the phone break — 33 domains want a real field"
|
|
wide = _declarations(mobile, ".gate-filter")
|
|
assert wide.get("width") == "100%", f"the phone rule reads {wide} rather than `width: 100%`"
|
|
assert wide.get("max-width") == "none", (
|
|
f"{wide} lifts the width but not the 18rem cap, so the field is still 288 px on a 320 px page")
|
|
|
|
for selector, body in re.findall(r"([^{}]*\.gate-filter[^{}]*)\{([^}]*)\}", _code(css)):
|
|
assert ":hover" not in selector, f"the filter grew a hover affordance: {selector.strip()}"
|
|
assert "cursor" not in body and "transition" not in body, (
|
|
f"the field advertises itself as a pointer control: {body.strip()}")
|
|
|
|
pages = {path: client.get(path).text for path in (*MARKUP_PAGES, "/login")}
|
|
html = pages[GATEWAY_PAGE]
|
|
head = html[html.index("Connection map"):]
|
|
field = next(tag for tag in re.findall(r"<input[^>]*>", head) if 'id="gate-filter"' in tag)
|
|
chip = next(tag for tag in re.findall(r"<span[^>]*>", head) if 'id="gate-filter-count"' in tag)
|
|
for attr in ('type="search"', 'class="gate-filter"', 'autocomplete="off"', 'spellcheck="false"'):
|
|
assert attr in field, f"the filter field lost {attr}"
|
|
assert 'aria-label="Filter routes by domain, endpoint or port"' in field, \
|
|
"the field has no accessible name — a placeholder is a hint, not a label"
|
|
assert "placeholder=" in field, "the field no longer says what it matches on"
|
|
assert 'class="chip faint"' in chip and "hidden" in chip \
|
|
and 'aria-live="polite"' in chip, f"the count chip is {chip}"
|
|
for other, page in pages.items():
|
|
if other != GATEWAY_PAGE:
|
|
assert 'type="search"' not in page, f"{other} grew a second search field"
|
|
|
|
# one no-match sentence per panel, and it ships hidden like every conditional sentence here
|
|
for key in ("nginx", "haproxy"):
|
|
card = html[html.index(f'data-gate="{key}"'):]
|
|
card = card[:card.index("</article>")]
|
|
assert card.count('data-k="no-match"') == 1, \
|
|
f"the {key} panel has no single no-match sentence"
|
|
assert 'data-k="no-match" hidden' in card, f"the {key} no-match sentence ships visible"
|
|
assert 'data-k="empty"' in card, \
|
|
f"the {key} panel lost the sentence for a config that names no port — the filter's " \
|
|
"sentence is a different fact"
|
|
|
|
# the footer says what the field does and does not touch, once
|
|
foot = " ".join(html[html.index("<footer class="):].split())
|
|
assert "hides the routes whose own labels do not contain what you typed" in foot, \
|
|
"the page stopped saying out loud that the filter asks for nothing"
|
|
assert "the tallies, the warnings and the charts above it are still the whole ingress" in foot, \
|
|
"the sentence no longer names the three things a filter must not quietly narrow"
|
|
for junk in ("no page narrates", "every value is scraped", "filters live, nothing is stored"):
|
|
assert junk not in foot, f"the footer grew flavour text: {junk}"
|
|
|
|
|
|
def test_haproxy_draws_straight_across_and_nginx_branches(client) -> None:
|
|
"""The geometry the operator asked for, in four rules and zero JavaScript.
|
|
|
|
"The dotted line connections need to connect to their counterparts. This is easy for haproxy —
|
|
just draw the line straight across. For nginx you'll need to branch the lines from `:443` to the
|
|
services they connect to." The answer is a spine plus a tick per leaf, and the spine is built out
|
|
of the rows themselves: each `.gate-link` carries its own upper half (`::before`) and lower half
|
|
(`::after`) at the gutter's left edge, and the first and last rows drop the half that would stick
|
|
out of the fan-out. That is why the spine starts at the first branch and stops at the last with
|
|
no mask, no measured height and no background-coloured eraser — and why it is still right on the
|
|
poll that adds a leaf, which any `ResizeObserver` version would have to be re-run to discover.
|
|
|
|
`.gate-group.solo`, set by the script from the group's own leaf count, takes the two halves away:
|
|
one route off a port is not a branch, and haproxy's six one-`server` frontends must read as one
|
|
straight line. `column-gap: 0` on the group is what makes the trunk's right edge and the spine
|
|
the same x, so the tick starts *at* the trunk rather than a gap short of it.
|
|
"""
|
|
section = _code(_css_section(_css(client), "gateway pipes"))
|
|
|
|
before = _declarations(section, ".gate-link::before")
|
|
after = _declarations(section, ".gate-link::after")
|
|
for name, rule in (("::before", before), ("::after", after)):
|
|
# `transparent`, not a colour: this rule is the branch's *geometry*, and the ink arrives from
|
|
# the row's state class (`test_the_whole_route_takes_one_existence_class`). A colour here would
|
|
# be a row drawing a confident line before any payload said the route exists.
|
|
assert rule.get("border-left") == "2px dashed transparent", (
|
|
f"the spine's {name} half no longer declares a dashed, colourless border — geometry "
|
|
"belongs on the row and colour belongs to `.gate-link.on`/`.off`")
|
|
assert rule.get("position") == "absolute" and rule.get("left") == "0", \
|
|
f"the spine's {name} half no longer sits in the row's gutter"
|
|
assert before.get("top") == "calc(var(--gate-leaf-gap) / -2)", \
|
|
"the upper half no longer starts half a leaf gap above its row"
|
|
assert after.get("top") == "50%", "the lower half no longer starts at the row's centre"
|
|
half = "calc(50% + var(--gate-leaf-gap) / 2)"
|
|
assert before.get("height") == half and after.get("height") == half, (
|
|
"a half no longer reaches half a gap past its own row, so consecutive rows stop meeting and "
|
|
"the spine becomes a stack of brackets; and if it reached further it would run past the ends "
|
|
"of the list, which the rule below is what prevents")
|
|
assert _declarations(section, ".gate-leaves").get("gap") == "var(--gate-leaf-gap)", \
|
|
"the leaf list's gap is a literal again, so the spine's reach and the gap can drift apart"
|
|
assert re.search(r"--gate-leaf-gap:\s*\.3rem", _code(_css(client))), \
|
|
"`--gate-leaf-gap` is no longer declared in `:root`"
|
|
assert _declarations(section, ".gate-link:first-child::before, .gate-link:last-child::after").get(
|
|
"display") == "none", "the fan-out grew a spine past its first and last branch"
|
|
elbow = _declarations(section, ".gate-elbow").get("border-top", "")
|
|
assert "2px dashed" in elbow and elbow.endswith("transparent"), (
|
|
"the tick from the spine to the switch is gone, or coloured itself, so a leaf no longer "
|
|
"reaches its trunk in the same ink as its run")
|
|
|
|
solo = _declarations(
|
|
section, ".gate-group.solo .gate-link::before, .gate-group.solo .gate-link::after")
|
|
assert solo.get("display") == "none", (
|
|
"a one-leaf group draws the vertical again — haproxy's six frontends would each grow a "
|
|
"branch stub where the line is supposed to run straight across")
|
|
assert _declarations(section, ".gate-group").get("column-gap") == "0", \
|
|
"the trunk and the spine are no longer the same x, so the tick starts in mid-air"
|
|
columns = _declarations(section, ".gate-link").get("grid-template-columns", "")
|
|
assert columns.startswith("26px "), \
|
|
f"the row's gutter is not a fixed 26px any more ({columns}), so the spine drifts"
|
|
|
|
# The phone stacks the trunk *above* its leaves, so the first row's upper half comes back and
|
|
# reaches into the group's row gap. Pinned inside the media query, because outside it this rule
|
|
# would be the desktop defect the paragraph above forbids.
|
|
mobile = section[section.index("@media (max-width: 640px)"):]
|
|
reach = re.search(r"\.gate-link:first-child::before\s*\{([^}]*)\}", mobile)
|
|
assert reach, "the stacked phone layout stopped reconnecting the trunk to its spine"
|
|
rules = _declarations(mobile, ".gate-link:first-child::before")
|
|
assert rules.get("display") == "block", "the reaching half is still switched off on a phone"
|
|
assert rules.get("top") == "calc(var(--gate-row-gap) * -1)", (
|
|
f"the reach is {rules.get('top')!r} rather than the group's own row gap negated — a literal "
|
|
"would leave the spine short of the trunk the day the rhythm moved")
|
|
assert "var(--gate-row-gap)" in rules.get("height", ""), \
|
|
"the reaching half grew by a different number than it moved"
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert "el.classList.toggle('solo', group.connections.length === 1)" in js, (
|
|
"`.solo` is no longer the group's own leaf count, so the straight-across rule is keyed on "
|
|
"something other than the thing it means")
|
|
assert "function gateLink" not in js, \
|
|
"a JS-measured connector came back; the branch is CSS and stays right without measuring"
|
|
|
|
|
|
def test_the_pipe_animation_stills_in_both_states_under_reduced_motion(client) -> None:
|
|
"""The one reduced-motion block names `.pipe.live`, `.pipe.still` **and** `.gate-switch-thumb`.
|
|
|
|
The menu's pair above (phase 20 task 03) is the precedent and the reason: the animated rule
|
|
out-specifies the base one, so stilling only the static state would leave every busy pipe
|
|
flowing for someone who asked for no motion. Same single block, because two blocks drift.
|
|
|
|
Phase 30 task 05 added the third name, and it is not decoration: the switch's knob slides on a
|
|
`transition` of its own, and a block that stills the pipes while the thumb keeps sliding is the
|
|
phase-18 lesson half-applied — the operator asked for no motion, not for less of it.
|
|
|
|
Phase 31 task 03 added a **second pipe** (the trunk's stub) and deliberately added no fourth name.
|
|
That works only because of how the two names are written, so it is pinned rather than assumed: the
|
|
selectors are **descendant** (`.pipe.live .pipe-path`), and the stub is built by the same
|
|
`buildPipe(trunk)` with the same `pipe pipe-trunk` class list and the same `.pipe-path` child. A
|
|
rule that reaches `.pipe.live .pipe-path` therefore reaches a trunk's path too. The failure this
|
|
prevents is the quiet one: someone gives `.pipe-trunk .pipe-path` its own `animation` to make the
|
|
stub "nicer", and the block below stops covering it.
|
|
"""
|
|
code = _code(_css(client))
|
|
block = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\n\}",
|
|
code, re.S)
|
|
assert block, "the stylesheet lost its reduced-motion block"
|
|
selector = re.search(r"(\.pipe\.live[^{]*)\{", block.group(1))
|
|
assert selector, "the reduced-motion block stopped naming the pipes at all"
|
|
named = {" ".join(part.split()) for part in selector.group(1).split(",")}
|
|
assert named == {".pipe.live .pipe-path", ".pipe.still .pipe-path"}, (
|
|
"the block must name both pipe states — naming only the static one leaves a live pipe "
|
|
f"animating for someone who asked for no motion; got {sorted(named)}")
|
|
assert _declarations(block.group(1), ".pipe.still .pipe-path").get("animation") == "none", \
|
|
"the named rules stopped stilling the animation"
|
|
assert _declarations(block.group(1), ".gate-switch-thumb").get("transition") == "none", \
|
|
"the switch's knob still slides when the operator asked for no motion"
|
|
# The two names are descendant selectors, which is the whole reason a second pipe needs no third:
|
|
# `.pipe.live` is on the `<svg>` (leaf or trunk) and `.pipe-path` is its child in both cases.
|
|
assert all(name.endswith(" .pipe-path") for name in named), (
|
|
f"the stilling rules are {sorted(named)} — a rule aimed at `.pipe.live` alone would not "
|
|
"reach the `<path>` that carries the `animation`, and would not reach a trunk's either")
|
|
|
|
# …and every animated thing on the page is in this block, counted rather than trusted: a third
|
|
# `transition` added to the map later has to be named here, not assumed to be covered.
|
|
animated = {".pipe.live .pipe-path", ".pipe.still .pipe-path", ".gate-switch-thumb"}
|
|
section = _code(_css_section(_css(client), "gateway pipes"))
|
|
transitioned = {" ".join(sel.split()) for sel, body in
|
|
re.findall(r"([^{}]+)\{([^}]*)\}", section)
|
|
if "transition:" in body or "animation:" in body}
|
|
assert transitioned <= animated, (
|
|
f"{sorted(transitioned - animated)} animates in the gateway map without being stilled")
|
|
|
|
# The trunk's stub must earn that coverage rather than assume it: it exists because `buildPipe`
|
|
# makes it (one class shape for both lines) and it owns no motion declaration of its own.
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert "function buildPipe(trunk = false)" in js and "'pipe pipe-trunk'" in js, \
|
|
"`.pipe-trunk` is no longer produced by the one pipe builder, so it is a second component "\
|
|
"with a class shape this block does not reach"
|
|
stub_rules = [body.strip() for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", section)
|
|
if ".pipe-trunk" in sel]
|
|
assert stub_rules, "the stylesheet lost the trunk stub's rule"
|
|
for body in stub_rules:
|
|
assert "animation" not in body and "transition" not in body, (
|
|
f"the trunk stub styles motion itself ({body!r}), which the stilling block above does "
|
|
"not name — motion belongs on `.pipe.live .pipe-path` and nowhere else")
|
|
|
|
|
|
def test_no_gateway_count_is_coloured_by_a_verdict(client) -> None:
|
|
"""The pipes and switches colour from **existence**, and the verdict vocabulary stays out.
|
|
|
|
Phase 30 task 05 gave `/gateway` its green, and the whole point of that green is that it is not
|
|
the severity language: `--gate-on` means *this route is written in a proxy config*, and
|
|
`--gate-off` means the opposite. It is an existence state, so it lives in its own pair of tokens
|
|
(`test_a_connection_is_green_because_it_is_in_the_config` pins the pair, where it is declared,
|
|
and that the base `.pipe-path` carries no colour of its own) and nowhere near `--ok`.
|
|
|
|
So the absence this test exists for is unchanged, and it is now the thing that keeps task 05's
|
|
green honest: a busy ingress is the job on this fleet, `severity.py` has no `gateway.*` row, and
|
|
`/api/gateway/snapshot` publishes no `severity` at any depth (task 03 pins that over HTTP), so no
|
|
line, count, tally or switch may be graded. The whole vocabulary is pinned absent — the tokens,
|
|
the class names, and the two JS helpers that would have applied a verdict — across the stylesheet
|
|
section, the script and the markup, comments stripped because prose is allowed to *name* a colour
|
|
the code must not paint.
|
|
|
|
Two things read `ok|warn|fail` and neither is a verdict on a number. The reachability **dot** is
|
|
the job's health, the same dot every other page's card header draws from the same `states` row.
|
|
And the **warnings list** borrows the overview's `li.warn` row shape verbatim, because "a route
|
|
that does not connect to itself" is the same kind of sentence `/` prints and it is `alerts.py`'s
|
|
verdict, not a number's — so the pin is that exactly one `className` assignment in the whole
|
|
script says `warn`, and it is inside `renderWarnings`, while every row, line, switch and tally
|
|
renderer stays free of it.
|
|
"""
|
|
section = _code(_css_section(_css(client), "gateway pipes"))
|
|
for token in ("--ok", "--warn", "--fail", ".ok", ".warn", ".fail", "severity"):
|
|
assert token not in section, f"the gateway stylesheet section paints with {token}"
|
|
# Phase 31 task 01 extended that single vocabulary to the **whole** row, and the two halves of
|
|
# this assertion are the same claim read two ways: every connector takes the existence pair, and
|
|
# nothing in this section any longer has a reason to name the structure token. `--border-strong`
|
|
# was where the spine and the tick lived while only the run went green, so its absence here *is*
|
|
# the statement "a route is green from its trunk to its endpoint, or grey for all of it".
|
|
assert "--border-strong" not in section, (
|
|
"a gateway connector reads structure ink again — the spine halves, the tick, the run and the "
|
|
"switch are one existence claim and must take `--gate-on`/`--gate-off` together")
|
|
for connector in (".gate-link.on::before", ".gate-link.on::after", ".gate-link.on .gate-elbow",
|
|
".gate-link.off::before", ".gate-link.off::after",
|
|
".gate-link.off .gate-elbow"):
|
|
assert connector in section, f"the row's state class no longer paints {connector}"
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
for helper in ("severityFrom", "severityFor", ".severity"):
|
|
assert helper not in js, f"/gateway grades a number with {helper}"
|
|
assert "setDot(" in js, "and the reachability dot is still drawn (that one is health)"
|
|
assigned = re.findall(r"className = '([^']*)'", js)
|
|
assert assigned.count("warn") == 1, (
|
|
f"`warn` is painted in {assigned.count('warn')} places; only the warnings list may say it")
|
|
warn_body = js[js.index("function renderWarnings("):js.index("function dotFor(")]
|
|
assert "li.className = 'warn'" in warn_body, (
|
|
"the one `warn` in the script is no longer the warnings row — re-check what is coloured")
|
|
for cls in assigned:
|
|
assert cls == "warn" or not any(v in cls for v in ("ok", "fail")), (
|
|
f"a verdict class ({cls}) entered the map itself")
|
|
# The confirm dialog's diff tints (phase 33 task 06) derive from the existence pair only:
|
|
# a `+` line the same green an in-config line does, a `-` line its red — a diff tinted
|
|
# with a verdict colour would be the map grading a number. The dialog is its own section,
|
|
# so the check runs there, on the diff rules alone.
|
|
diff_rules = "\n".join(body for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", _code(_css(client)))
|
|
if "#gate-switch-dialog" in sel)
|
|
assert 'var(--gate-on)' in diff_rules and 'var(--gate-off)' in diff_rules, (
|
|
"the diff tints left the existence pair — a `+` line and a `-` line must read as the "
|
|
"same red/green the rows do")
|
|
for token in ("--ok", "--warn", "--fail", ".ok", ".warn", ".fail", "severity"):
|
|
assert token not in diff_rules, f"the dialog's diff rules paint with {token}"
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
for token in ('class="ok', 'class="warn', 'class="fail', "severity"):
|
|
assert token not in html, f"/gateway markup carries {token}"
|
|
|
|
|
|
def test_gateway_charts_are_collapsed_lazy_and_cost_one_history_request_per_card(
|
|
client) -> None:
|
|
"""`/system`'s chart discipline, per card: shut, built on open, one request per refresh.
|
|
|
|
Three separate things, pinned because `/system` and `/disks` each lost one of them in review.
|
|
The disclosure ships without `open`; `drawCardCharts` returns while it is shut, so no
|
|
`LineChart` exists and no SVG is painted for a plot nobody is looking at (`LineChart` measures
|
|
its container, and a hidden one is 0 px wide); and `renderCardCharts` fires once per visible
|
|
card per refresh naming every gauge that card's pipes publish — the endpoint answers an
|
|
unknown name as an empty series on purpose, so asking for the whole card costs nothing.
|
|
"""
|
|
html = client.get(GATEWAY_PAGE).text
|
|
assert '<details class="charts-box"' in html and '<details open' not in html
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert 'addEventListener("toggle"' in js or "addEventListener('toggle'" in js
|
|
draw = js[js.index("function drawCardCharts("):js.index("async function renderCardCharts(")]
|
|
assert draw.index("if (!chartsOpen(key)") < draw.index("cardChart(key)"), \
|
|
"the paint pass builds charts before it asks whether the stack is open"
|
|
fetch = js[js.index("async function renderCardCharts("):js.index("function sumLive(")]
|
|
assert "/api/gateway/history" in fetch and "&range=" in fetch
|
|
assert fetch.count("fetchJSON") == 1, "a card now costs more than one history request"
|
|
assert "snapshot.host" in fetch, \
|
|
"the request must name the ingress and no other host — that is what the gauges are stored under"
|
|
refresh = js[js.index("async function refresh("):js.index("function pruneCards(")]
|
|
assert "renderCardCharts(key, snapshot)" in refresh and "visible.map" in refresh, \
|
|
"the history pass is no longer one request per visible card per refresh"
|
|
assert js.count("ariaLabel:") == 1, "the per-card plot lost or duplicated its accessible name"
|
|
assert "chart.series.filter((s) => s.points && s.points.length)" in js, \
|
|
"the legend is naming pipes the plot never drew — a permanent `n/a` row per map target"
|
|
|
|
|
|
def test_the_page_reports_no_card_and_no_cell_for_sockets_the_configs_do_not_name(
|
|
client) -> None:
|
|
"""The deletion, pinned as an absence so it cannot come back quietly.
|
|
|
|
Phase 29 shipped an `unmatched` card (three cells: ports no config names, connections inbound,
|
|
connections outbound) and a `by port` chip, both of which existed to report the residual of a
|
|
join that could only ever credit a **port**. On the live router that residual was mostly this
|
|
app's own agent (`:9120`) and SSH — noise dressed as a signal, and a card of it teaches the
|
|
operator to ignore the panel. So this pins that neither the markup, nor the script, nor the
|
|
stylesheet carries the vocabulary any more, in the page's own words and in the payload's.
|
|
|
|
What replaced it stays pinned too: the header's live tally still refuses to total a side that
|
|
did not measure (`0` there would be a claim about traffic nobody counted), and still dims when a
|
|
port that should have had a number did not.
|
|
"""
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
css = _code(_css(client))
|
|
for name, text in (("markup", html), ("script", js), ("stylesheet", css)):
|
|
for token in ("unmatched", "um-other", "um-in", "um-out", "gate-groups", "by_port",
|
|
"by port", "pipe-columns", "pipe-node", "pipe-col", "solo-out", "solo-in"):
|
|
assert token not in text, f"/gateway's {name} still carries {token!r}"
|
|
assert 'data-gate="map"' not in html, "the stream map grew its panel back"
|
|
|
|
# The rules that survived the deletion, in the function that computes the header's live total
|
|
# and the one that paints it.
|
|
sums = js[js.index("function sumLive("):js.index("function setTally(")]
|
|
assert "unknown += 1" in sums and "attributed" in sums
|
|
assert "unknown === counted.length ? null" in sums, \
|
|
"a side where nothing measured started reporting a zero total"
|
|
assert "filter((row) => row.metric)" in sums, (
|
|
"a row that publishes no gauge is not an unmeasured port — it is absent, and counting it "
|
|
"would hold the total hostage")
|
|
tally = js[js.index("function setTally("):js.index("function renderHeader(")]
|
|
assert "classList.toggle('stale'" in tally, \
|
|
"a total that left a port out stopped saying it was partial"
|
|
assert "sum == null ? null : sum.attributed" in tally, \
|
|
"an unmeasured side must print n/a, not 0"
|
|
|
|
|
|
def test_the_page_says_where_a_line_comes_from_and_where_its_count_cannot(client) -> None:
|
|
"""Four footnotes, one per claim the page is allowed to make, and not one more.
|
|
|
|
Why the page draws a line at all (the config declares the route) and what its motion means (a
|
|
socket is open this instant, so a resting line is not a dead one); why a route carries no number
|
|
(phase 32 task 04: **the gateway could not resolve that name in this reading** — the gateway does
|
|
the resolving, so the sentence blames the reading and points at the hover that shows the address a
|
|
counted line was matched to, instead of blaming the config for having written a name or the page
|
|
for having no resolver); what the switch does with the flag set and without (phase 33 task 06:
|
|
activating it switches the route in its config file only when `GATEWAY_ALLOW_WRITES=1`; otherwise
|
|
it is a reading that does nothing, and its title says so); and — since phase 31 task 04 — what
|
|
the filter box does to the map
|
|
and what it leaves alone (the tallies, the warnings and the charts keep describing the whole
|
|
ingress, because the field asks for nothing).
|
|
Each lives in the footer exactly once — the page does not repeat a why-line per panel, and it does
|
|
not need to: an uncounted line prints `—` and the footnote is the sentence that explains it
|
|
(rule 2).
|
|
"""
|
|
html = client.get(GATEWAY_PAGE).text
|
|
footer = html[html.index("<footer"):]
|
|
assert footer.count("a dash means") == 1, (
|
|
"the page's single footnote about a missing count, printed once, in the phase-32 reason")
|
|
assert "could not resolve that name" in footer, (
|
|
"a dash means the gateway's reading came back with nothing, not that the config wrote a name")
|
|
# The two sentences phase 32 made stale may not come back from either file: the blame on the page
|
|
# (`the page knows no`) and its no-DNS limit as a per-row excuse, which task 04 replaced with the
|
|
# thing to do about a dash rather than a reason to accept it.
|
|
assert "the page knows no" not in footer, (
|
|
"the dash stopped blaming the gateway's reading and went back to blaming the page")
|
|
# The served source wraps this paragraph, so the two halves are matched separately: what is
|
|
# pinned is that the sentence names a cause **and** the place to look.
|
|
flat = " ".join(footer.split())
|
|
assert "hover a counted line for the address it matched" in flat, (
|
|
"the footnote names a cause and no longer says where the address and its age are")
|
|
script = client.get("/static/page-gateway.js").text
|
|
assert "the gateway could not resolve this name in this reading" in script, (
|
|
"the per-row tooltip must say the same thing the footnote says")
|
|
assert "nothing here resolves names" not in script, (
|
|
"the old tooltip sentence blames the name, which is no longer why a line has no count")
|
|
assert "animates" in footer, "the animation rule lost its footnote"
|
|
band = footer[:footer.index("</footer>")]
|
|
# Four paragraphs and nothing else: one sentence per claim, in one ink band under the panels.
|
|
assert band.count("<p") == 4, f"the footer is {band.count('<p')} paragraphs, not four"
|
|
assert "<span" not in band, "the footer grew a second element inside a sentence"
|
|
assert "the config says the route exists" in band, "the page stopped saying what a line means"
|
|
assert "GATEWAY_ALLOW_WRITES" in band and "does nothing" in band, \
|
|
"the switch stopped saying what it does — and what it does when the flag is set"
|
|
assert "coloured by a verdict" in band and "address" in band, \
|
|
"a claim the page still has to make left with the rewrite"
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
counts = js[js.index("function countText("):js.index("function groupsFor(")]
|
|
assert "return null" in counts or ": null" in counts, \
|
|
"an unmeasured count must stay null for setTxt to print n/a"
|
|
|
|
|
|
def test_the_gateway_page_renders_each_degradation_arm_in_the_payloads_own_words(
|
|
client, gateway_client) -> None:
|
|
"""Four arms, four sentences, none of them invented in the browser.
|
|
|
|
* **unconfigured** — `404 {configured: false}`. The route's own refusal names `GATEWAY_HOST`,
|
|
and the page's setup block is the other half of the same sentence; the panels stay hidden
|
|
because there is nothing to draw and nothing to age (invariant 3).
|
|
* **a host that has never answered** and **a pre-1.2.0 / `--no-proxy` agent** — both dim
|
|
(`unreachable`, or `supported !== true`) rather than redden, and both arrive with
|
|
`payload.why` already written by `sources/gateway.py` (with the agent's own error line when it
|
|
had one), so the page prints `#gate-banner` from the payload and never re-derives a sentence of
|
|
its own about someone else's config.
|
|
* **a socket table that did not answer** — the configs still draw and only the counts go `n/a`.
|
|
* every case that did not measure keeps its numbers as `n/a`, which `setTxt` does with a `null`
|
|
and which `countText` is the only gate for — there is no `|| 0` anywhere on a count, because a
|
|
zero is a claim about traffic and `n/a` is not.
|
|
"""
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
empty = html[html.index('id="gate-empty"'):html.index('</div>', html.index('id="gate-empty"'))]
|
|
assert "GATEWAY_HOST" in empty and "1.2.0" in empty
|
|
assert 'id="gate-banner"' in html
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
assert "$('#gate-banner')" in js and "snapshot.why" in js
|
|
assert "err.status !== 404" in js, (
|
|
"only the unconfigured refusal may raise the setup block; a failed read keeps the drawing")
|
|
assert "markStale(" in js and "function dimmed(" in js, \
|
|
"an arm that could not measure must dim, not redden"
|
|
dimmed = js[js.index("function dimmed("):js.index("function cardElements(")]
|
|
assert "unreachable" in dimmed and "supported !== true" in dimmed, \
|
|
("the dim is keyed on something other than the two arms that did not measure — a host that "
|
|
"has never answered and an agent that predates the sections are the `.stale` cases")
|
|
counts = js[js.index("function countText("):js.index("function groupsFor(")]
|
|
assert "return null" in counts or ": null" in counts, \
|
|
"countText no longer turns the unknown into the `n/a` setTxt prints"
|
|
assert not re.search(r"connections\s*\|\|\s*0", js), "a missing count became a claimed zero"
|
|
|
|
# …and the tallies obey the same rule over a real payload: an arm that measured nothing prints
|
|
# `n/a` in every cell rather than four zeros about a config nobody read.
|
|
silent = gateway_client.get("/api/gateway/snapshot").json()
|
|
header = js[js.index("function renderHeader("):js.index("async function refresh(")]
|
|
assert "measured ?" in header and "!dimmed(snapshot)" in header, (
|
|
"the tallies are no longer keyed on the two arms that did not measure")
|
|
assert silent["sockets"]["established"] >= 0, "the control: this payload did measure"
|
|
|
|
stub = dict(silent)
|
|
stub["supported"] = None
|
|
stub["unreachable"] = True
|
|
stub["listeners"] = []
|
|
stub["connections"] = []
|
|
assert sum(1 for field in ("listeners", "connections") if stub[field]) == 0, (
|
|
"the stub arm's rows are not empty, so the header's `n/a` would not be about the same thing")
|
|
|
|
# (5) a **resolver** that did not answer (agent 1.2.0, `--no-resolve`, or a pass that could not
|
|
# run) is the fifth arm and the quietest: the section is not one of the two config files, so
|
|
# it raises no banner and no colour, and every name-written line simply carries the dash with
|
|
# the resolution sentence. `full` is that document — no `resolver` key at all.
|
|
assert silent["resolver"]["available"] is None and silent["resolver"]["entries"] == [], (
|
|
"the `full` capture is supposed to be the no-resolver arm this case measures")
|
|
assert silent["why"] is None, "an absent resolver section became a page-wide complaint"
|
|
assert "severity" not in json.dumps(silent), "the fifth arm arrived with a verdict on it"
|
|
dashes = [row for row in silent["connections"] if row["connections"] is None]
|
|
assert dashes, "a document with no resolver counted a name-written route"
|
|
assert all(count_surfaces(row)["why"] == COUNT_WHY_UNRESOLVED for row in dashes), \
|
|
"a line nobody resolved is being explained by something other than resolution"
|
|
assert not any(count_surfaces(row)["glyph"] == "0" for row in dashes), \
|
|
"the uncounted became a claimed zero"
|
|
|
|
|
|
def test_the_gateway_page_keeps_its_last_drawing_when_a_poll_fails(client) -> None:
|
|
"""Invariant 4 on the fifth page: a failed read leaves the last picture on screen.
|
|
|
|
The cards are hidden **by the payload**, never by the failure path — a `fetchJSON` rejection
|
|
(a proxy hiccup, a server mid-restart) updates the status line and returns, so the operator
|
|
keeps the topology they were reading instead of an empty grid whose emptiness would read as
|
|
"this gateway routes nothing".
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
refresh = js[js.index("async function refresh("):js.index("function pruneCards(")]
|
|
guard = refresh[:refresh.index("$('#gate-empty').hidden = true")]
|
|
assert "catch" in guard and "$('#gate-cards')" not in guard, \
|
|
"the failure path blanks the card grid — the last good drawing must survive it"
|
|
assert "$('#sub').textContent = err.message" in guard, \
|
|
"and the reason has to be printed where the operator is already looking"
|
|
|
|
|
|
COUNT_WHY_UNRESOLVED = "the gateway could not resolve this name in this reading"
|
|
COUNT_WHY_NO_SOCKETS = "the socket table did not answer this reading"
|
|
|
|
|
|
def count_surfaces(row: dict[str, Any]) -> dict[str, Any]:
|
|
"""The count column's two strings for one route, as `countWhy` / `countTitle` build them.
|
|
|
|
A **second** reading of the same decision, with the served-byte pins below as its other half:
|
|
|
|
* `glyph` — the em dash for a null, the number otherwise (`0` included, because a resolved
|
|
endpoint with no socket is a measurement and not a gap);
|
|
* `why` — for a null, which of the two unknowns the payload describes: an address is **known**
|
|
when the config wrote one (`out.address is True`) or the gateway published one
|
|
(`out.resolved` non-empty), and a row with neither is a name nothing could resolve;
|
|
* `title` — for a counted row, the number plus the provenance the tooltip owes it. The **age**
|
|
fragment is left out here and pinned as a prefix instead, because it is a clock: the test
|
|
asserts what comes before it and what the remainder looks like.
|
|
"""
|
|
out = row["out"]
|
|
shown = row["connections"]
|
|
if shown is None:
|
|
known = out["address"] is True or bool(out["resolved"])
|
|
why = COUNT_WHY_NO_SOCKETS if known else COUNT_WHY_UNRESOLVED
|
|
return {"glyph": "\u2014", "why": why, "title": f"no number: {why}",
|
|
"aria": f"connections not attributed: {why}", "age": False}
|
|
title = f"{shown} open connection(s) on this endpoint"
|
|
via = out["via"] if isinstance(out["via"], str) and out["via"] else None
|
|
if out["resolved"] is None or via is None:
|
|
return {"glyph": str(shown), "why": None, "title": title, "age": False,
|
|
"aria": f"{shown} live connections"}
|
|
title += f" \u00b7 address from {via}"
|
|
# `Dash.duration(Dash.ageSeconds(ts))`, whose text is a clock and therefore not this mirror's to
|
|
# reproduce — only the *decision* to print it is.
|
|
return {"glyph": str(shown), "why": None, "title": title,
|
|
"age": out["resolved_ts"] is not None, "aria": f"{shown} live connections"}
|
|
|
|
|
|
def rendered_leaf(row: dict[str, Any]) -> dict[str, str]:
|
|
"""The two lines and the two long strings one leaf row prints, derived as a **second** reading.
|
|
|
|
There is no JS runner (a locked anchor), so this is `endpoint_sums`' idiom: the figure is
|
|
recomputed here from the payload, and the served-byte pins are what keep the mirror honest —
|
|
`inboundText` joins `out.selectors` and falls back to `out.kind`, `endpointText` is `out.host`
|
|
+ `:` + `out.port`, `counterpartText` is `ARROW` + endpoint + ` · out.kind`, and `leafTitle` /
|
|
`leafAria` compose those same two from the literals asserted verbatim below. A rename on either
|
|
side therefore fails a pin **and** changes what this mirror prints, which is the pair of
|
|
guarantees a page with no test runner can actually make.
|
|
|
|
The row's `aria-label` ends with the count's claim, and phase 32 task 04 made that claim carry its
|
|
cause: `count_surfaces()` is the one place that sentence is assembled, so the row and the count
|
|
element cannot disagree about which unknown a dash is.
|
|
"""
|
|
out = row["out"]
|
|
host = out["host"] or "?"
|
|
port = out["port"]
|
|
endpoint = f"{host}{'' if port is None else f':{port}'}"
|
|
kind = out["kind"]
|
|
inbound = " ".join(out["selectors"]) or kind
|
|
pair = (f"{inbound} " if inbound else "") + f"→ {endpoint}" + (f" · {kind}" if kind else "")
|
|
shared = " — this endpoint is shared with another route" if row["shared"] is True else ""
|
|
bits = [f"connection from :{row['in']['port']} to {endpoint}"]
|
|
if out["selectors"]:
|
|
bits.append("reached by " + ", ".join(out["selectors"]))
|
|
if out["name"]:
|
|
bits.append(f"server {out['name']}")
|
|
if row["shared"] is True:
|
|
bits.append("this endpoint is shared with another route")
|
|
bits.append("in the config")
|
|
bits.append(count_surfaces(row)["aria"])
|
|
return {"line1": inbound,
|
|
"line2": f"→ {endpoint}" + (f" · {kind}" if kind else ""),
|
|
"title": pair + shared,
|
|
"aria": ", ".join(bits)}
|
|
|
|
|
|
def test_a_clipped_pipe_label_keeps_its_full_text_on_the_element(client, gateway_client) -> None:
|
|
"""The ellipsis is only honest while **both ends** are on the element, and pinned so.
|
|
|
|
Phase 29 task 06 measured 28 of 42 labels ellipsised at 1440 px and found the old fix (letting the
|
|
label wrap) worse than the clip — the flex line squeezed the name to seven characters of a
|
|
40-character target, and wrapping the text inside that box tore one hostname over eleven lines.
|
|
Phase 30 task 04's trunk→leaf row re-measured it (`data/logs/30-task04-panels.out`): **0 of 33**
|
|
endpoint labels clip at 1440 px (a 391 px target column) and **27 of 33** do at 360 px (177 px), so
|
|
what remains is a phone column-width fact (AGENTS.md gap 13) and not a lost one.
|
|
|
|
Phase 31 task 02 made that sentence twice as important, because the row now prints two strings and
|
|
both are the answer: `brain.reeseapps.com` on line 1 and
|
|
`→ reeseapps-brain-of-reese.reeselink.com:443` on line 2. So the claim is strengthened from "the
|
|
target survives the clip" to **every inbound word and the whole `host:port` appear in full in the
|
|
row's `title` *and* in the pipe's `aria-label`**, for every route the capture declares. Three
|
|
things make that true rather than aspirational: neither text helper cuts a string (no `slice`, no
|
|
`substring`, no ellipsis character — an ellipsis is CSS's job and CSS does not touch `title`),
|
|
`leafTitle` composes `inboundText` with `endpointText`, and `leafAria` names the endpoint and every
|
|
selector. The payload half then runs that over the real rows, longest name included.
|
|
"""
|
|
css = _code(_css(client))
|
|
assert _declarations(css, ".pipe-name").get("text-overflow") == "ellipsis", \
|
|
".pipe-name no longer truncates, so re-measure the wrap decision this pin documents"
|
|
assert _declarations(css, ".gate-selector").get("text-overflow") == "ellipsis", \
|
|
"the trunk's name line no longer truncates — its `title` is what makes that honest"
|
|
# …and the leaf's counterpart line, which is now the longer of the row's two strings and so the
|
|
# one that actually clips. Same trio, same reason: a truncation is only honest while `title` and
|
|
# the `aria-label` below carry the whole thing.
|
|
end = _declarations(css, ".pipe-end")
|
|
assert end.get("text-overflow") == "ellipsis", \
|
|
".pipe-end no longer truncates — re-measure which of the row's two lines clips (gap 13)"
|
|
assert end.get("min-width") == "0" and end.get("white-space") == "nowrap", \
|
|
".pipe-end lost part of the truncation trio, so a long counterpart grows the card"
|
|
# …and the two declarations that decide *how much* it truncates. Task 06 measured the old page
|
|
# showing seven characters of a 40-character target, because a `nowrap` flex row shrinks the name
|
|
# to what the chip, the footnote and the `flex: none` count leave. `max-content` as its base size
|
|
# means a name longer than the column is alone on its line and gets the whole column, with the
|
|
# count, clients and domain list wrapping beneath; `min-width: 0` on the target column is what
|
|
# lets a long hostname ellipsise instead of widening the card (phase 27's grid rule).
|
|
assert "wrap" in _declarations(css, ".gate-trunk").get("flex-wrap", ""), \
|
|
"a long trunk label is squeezed beside its count again"
|
|
assert "max-content" in _declarations(css, ".pipe-name").get("flex", ""), \
|
|
"the name no longer claims the line, so it shrinks to the leftover width"
|
|
assert _declarations(css, ".gate-target").get("min-width") == "0", \
|
|
"a long target widens its card instead of ellipsising inside it"
|
|
assert "minmax(0, 1fr)" in _declarations(css, ".gate-link").get("grid-template-columns", ""), \
|
|
"the target track no longer permits 0, so a hostname grows the card"
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
row = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
|
assert "const inbound = inboundText(conn.out)" in row, \
|
|
"the row no longer builds both of its strings from one pair of helpers"
|
|
assert "li.title = title" in row, "a clipped connection line lost the full text on the element"
|
|
title = js[js.index("function leafTitle("):js.index("function buildPipe(")]
|
|
assert "inboundText(out)" in title and "endpointText(out)" in title, \
|
|
"the row's tooltip stopped carrying one of the row's two ends — half a route is no answer"
|
|
aria = js[js.index("function leafAria("):js.index("function buildPipe(")]
|
|
assert "endpointText(out)" in aria and "selectors.join" in aria, \
|
|
"the accessible sentence stopped carrying the whole endpoint or every inbound word"
|
|
# Nothing in the row's text helpers may cut a string. The CSS ellipsis is honest only because the
|
|
# data behind it is whole, and a `slice(0, 30)` in `counterpartText` would eat the tooltip too.
|
|
helpers = js[js.index("function endpointText("):js.index("function buildPipe(")]
|
|
for cutter in ("slice(", "substring(", "substr(", "trim(", "\u2026"):
|
|
assert cutter not in helpers, (
|
|
f"a leaf's text helpers cut strings with {cutter!r} — truncation belongs to CSS so the "
|
|
"`title` and the `aria-label` can carry the whole route (gap 13)")
|
|
|
|
# …and the claim over every route the captured ingress actually declares, longest name first:
|
|
# `reeseapps-brain-of-code.reeselink.com:443`, 41 characters, is this fleet's longest endpoint and
|
|
# the one the 17-row `:443` fan-out clips at a phone width.
|
|
rows = gateway_client.get("/api/gateway/snapshot").json()["connections"]
|
|
assert rows, "the captured ingress declares no routes, so this pin would prove nothing"
|
|
longest = max(rows, key=lambda r: len(f"{r['out']['host']}:{r['out']['port']}"))
|
|
longest_text = f"{longest['out']['host']}:{longest['out']['port']}"
|
|
# 42 characters, which is the figure AGENTS.md gap 13 measures the phone clip against. The name
|
|
# itself is pinned as a family rather than a string because this fleet has two 41/42-character
|
|
# neighbours (`…-brain-of-reese` and `…-brain-of-code`) and which one is longest is a fact about
|
|
# `stream.conf`, not a contract; the **length** is the thing the clip depends on.
|
|
assert len(longest_text) == 42 and longest["out"]["host"].startswith("reeseapps-brain-of-"), (
|
|
f"the fleet's longest endpoint is now {longest_text!r} ({len(longest_text)} chars) — gap 13's "
|
|
"clip measurement and gap 13's prose both have to be re-read")
|
|
for conn in rows:
|
|
printed = rendered_leaf(conn)
|
|
whole = f"{conn['out']['host']}:{conn['out']['port']}"
|
|
for surface in ("title", "aria"):
|
|
assert whole in printed[surface], (
|
|
f"{whole} is not whole in the row's {surface} — gap 13: whatever CSS clips, the "
|
|
"element has to carry the full counterpart")
|
|
for selector in conn["out"]["selectors"]:
|
|
assert selector in printed["title"] and selector in printed["aria"], (
|
|
f"the inbound word {selector!r} is missing from a surface that is supposed to survive "
|
|
"the clip — the row's answer is *which domain goes where*, so both ends are needed")
|
|
|
|
|
|
def test_the_leaf_row_leads_with_the_inbound_word(client, gateway_client) -> None:
|
|
"""Line 1 is the domain the operator types; line 2 is the counterpart it flows to.
|
|
|
|
The operator's complaint (2026-10-23): *"I can't tell which `reeseapps.com` or
|
|
`stackexpected.com` addresses flow to their reeselink counterparts (very important)."* Phase 30
|
|
drew the **target** on the primary line and demoted the inbound `server_name` / `map` key to an
|
|
11px faint qualifier, which is exactly backwards from the question the page exists to answer. So
|
|
the row re-ranked: `.pipe-name` is the config's inbound word and `.pipe-end` is
|
|
`→ host:port · kind` underneath it.
|
|
|
|
Three pins keep that from being a cosmetic swap that quietly invents something:
|
|
|
|
* `inboundText` reads `out.selectors` and falls back to `out.kind` — **never** `out.host`, because
|
|
picking the target as the "best name" is a fact the two config files never stated (rule 2);
|
|
* all the selectors are joined, so a merged connection (two vhosts claiming one
|
|
`(in_port, endpoint)`) shows both rather than the page choosing one;
|
|
* every field the two helpers index is in `GATEWAY_PAGE_FIELDS` **and** resolves on a live
|
|
`GET /api/gateway/snapshot` row, in both directions, so a rename in `sources/gateway.py` fails
|
|
this test by name instead of printing `undefined` beside a healthy ingress.
|
|
|
|
The last block is the complaint itself, asserted on the capture: the `brain.reeseapps.com` leaf of
|
|
nginx's `:443` reads that domain on line 1 and
|
|
`reeseapps-brain-of-reese.reeselink.com:443` on line 2, every haproxy leaf leads with its `backend`
|
|
name, and no row's primary line is empty, `null` or `undefined`.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
|
|
# (1) the two text helpers, and the one field each may never read.
|
|
head = js[:js.index("function countText(")]
|
|
assert "const ARROW = '\u2192'" in head, "the destination marker is no longer a page constant"
|
|
inbound = js[js.index("function inboundText("):js.index("function counterpartText(")]
|
|
assert "(out || {}).selectors" in inbound and ".join(' ')" in inbound, \
|
|
"line 1 stopped printing every selector the config wrote"
|
|
assert "(out || {}).kind" in inbound, \
|
|
"a row with no selector has no fallback, so its primary line would be empty"
|
|
assert "host" not in inbound and "port" not in inbound, \
|
|
"line 1 reaches for the target again — the counterpart is line 2's job and nothing else"
|
|
counterpart = js[js.index("function counterpartText("):js.index("function leafAria(")]
|
|
assert "${ARROW} ${endpointText(out)}" in counterpart, \
|
|
"line 2 stopped marking the endpoint as the destination"
|
|
assert "\u00b7 ${kind}" in counterpart, \
|
|
"line 2 lost the config's own word for the route kind, so a `map` leaf and a vhost leaf "\
|
|
"reaching one address became indistinguishable"
|
|
|
|
# (2) the call sites: line 1 is the inbound word, line 2 the counterpart, and the primary line
|
|
# is never handed the endpoint as its *first* choice.
|
|
row = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
|
assert "const inbound = inboundText(conn.out)" in row
|
|
assert "const primary = inbound == null ? endpointText(conn.out) : inbound" in row, \
|
|
"a row with no selector would print an empty primary line instead of its endpoint"
|
|
assert "name.textContent = primary" in row
|
|
assert not re.search(r"name\.textContent\s*=\s*endpointText", row), \
|
|
"the endpoint is back on the primary line — that is the reading order this task replaced"
|
|
assert "setTxt(counterpart, counterpartText(conn.out))" in row, \
|
|
"line 2 is no longer the counterpart"
|
|
assert "counterpart.hidden = inbound == null" in row, \
|
|
"the hidden rule no longer keys on the fallback, so a row could print its target twice"
|
|
markup = js[js.index("function leafRow("):js.index("function renderLeaf(")]
|
|
assert "counterpart.className = 'pipe-end mono'" in markup, \
|
|
"line 2 is no longer mono, so the two hostname columns stopped aligning down a fan-out"
|
|
assert "target.appendChild(name)" in markup and "target.appendChild(counterpart)" in markup, \
|
|
"the two lines are no longer both children of the target column"
|
|
|
|
# (3) the field table resolves in both directions for what the two helpers index.
|
|
needed = ("connections.out.selectors", "connections.out.kind",
|
|
"connections.out.host", "connections.out.port")
|
|
for path in needed:
|
|
assert path in GATEWAY_PAGE_FIELDS, (
|
|
f"the leaf row indexes {path.removeprefix('connections.')} but `GATEWAY_PAGE_FIELDS` "
|
|
"does not name it, so a rename in `sources/gateway.py` would print `undefined` here")
|
|
payload = gateway_client.get("/api/gateway/snapshot").json()
|
|
rows = payload["connections"]
|
|
assert rows, "the capture routes nothing, so the field table would resolve over an empty list"
|
|
for path in needed:
|
|
found, _values = _payload_field(payload, path)
|
|
assert found, f"/gateway's row reads {path}, which the payload does not publish"
|
|
|
|
# (4) the complaint, on the capture: `brain.reeseapps.com` → its reeselink counterpart.
|
|
brain = [conn for conn in rows if conn["out"]["selectors"] == ["brain.reeseapps.com"]]
|
|
assert brain, "the captured stream `map` no longer routes brain.reeseapps.com"
|
|
printed = rendered_leaf(brain[0])
|
|
assert printed["line1"] == "brain.reeseapps.com", \
|
|
f"line 1 reads {printed['line1']!r}, not the inbound domain the operator reads for"
|
|
assert "reeseapps-brain-of-reese.reeselink.com:443" in printed["line2"], \
|
|
f"line 2 reads {printed['line2']!r} — the counterpart is what the row exists to show"
|
|
assert brain[0]["in"]["port"] == 443 and brain[0]["source"] == "nginx", \
|
|
"that leaf no longer hangs off the stream `map`'s port in the nginx panel"
|
|
haproxy = [conn for conn in rows if conn["source"] == "haproxy"]
|
|
assert haproxy, "the capture's haproxy routes are gone, so this half proves nothing"
|
|
for conn in haproxy:
|
|
assert rendered_leaf(conn)["line1"] == conn["out"]["selectors"][0], \
|
|
"a haproxy leaf no longer leads with its `backend` name"
|
|
assert any(rendered_leaf(c)["line1"] == "default" for c in rows), \
|
|
"the map's own word for unmapped SNI was paraphrased off the primary line"
|
|
for conn in rows:
|
|
line1 = rendered_leaf(conn)["line1"]
|
|
assert line1 and line1 not in ("null", "undefined", "None"), (
|
|
f"a row's primary line prints {line1!r} — every route has an inbound word, and an empty "
|
|
"primary line is the bug the fallback exists to prevent")
|
|
|
|
# (5) the trunk is untouched by the re-rank: it still leads with its port and still refuses in
|
|
# the script's own sentence when its config names no target (this fleet's `:80` `return 301`).
|
|
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
|
assert "name.textContent !== port" in trunk, "the trunk no longer prints its port as line 1"
|
|
assert "no target named in the config" in js, "the `:80` refusal sentence is gone"
|
|
|
|
|
|
def test_the_switch_is_wired_only_when_the_deployment_enables_writes(
|
|
client) -> None:
|
|
"""Phase 33 task 06: the switch's two arms, pinned in both directions.
|
|
|
|
The page reads `gateway.writes_enabled` from `/api/config` — never inferred, because a fetch
|
|
that never resolves leaves the flag false, which is the safe side of a wrong guess. The pill
|
|
it builds is one of two:
|
|
|
|
* **writes off** (the shape this page shipped with, and the default): a reading shaped like a
|
|
control. `aria-disabled="true"` — never the bare `disabled` attribute, which would take the
|
|
reading out of the a11y tree — a `title` that is `api.py`'s own gate-5 refusal copied
|
|
verbatim, `cursor: not-allowed`, and no listener.
|
|
* **writes on**: a real control. No `aria-disabled`, `cursor: pointer`, a focusable
|
|
`<button>` whose Enter/Space press fires the one `click` listener, and an accessible name
|
|
that ends in the verb — what an activation does.
|
|
|
|
The flow the click runs, in the order that is the safety: the server's dry run first (its word
|
|
for what the change will do, before the operator is asked for the word), the dialog's labelled
|
|
diff and the typed `DANGER` (both verbs — either one can cut a route through the fleet's only
|
|
ingress), one send with `aria-busy`, and the paint is the answer's — `applied: true` flips
|
|
only the switch's own `aria-checked` (the one optimistic thing a click may create, held until
|
|
the payload catches up; the row's word, class and spine stay the payload's), `applied: false`
|
|
flips nothing and prints the helper's word verbatim, and one `refresh()` afterwards makes the
|
|
row's word the payload's.
|
|
"""
|
|
raw = client.get("/static/page-gateway.js").text
|
|
js = _code(raw)
|
|
|
|
# The bare-`disabled` audit, scoped to where the attribute is allowed: the switch may never
|
|
# wear it (it would take the reading out of the a11y tree), and the only `disabled` this file
|
|
# writes is the dialog's Apply button — the one control that may be taken out of the tree
|
|
# while a send is in flight, which the dialog's `aria-busy` announces anyway. (The title
|
|
# constant is the API's own sentence, and it may say the word.)
|
|
bare = [ln.strip() for ln in js.splitlines()
|
|
if re.search(r"(?<!aria-)\bdisabled\b", ln)
|
|
and "WRITES_OFF_TITLE = " not in ln]
|
|
assert all("slot('send').disabled" in ln for ln in bare), (
|
|
f"bare `disabled` left the dialog's Apply button: {bare} — on the switch it would take the "
|
|
"reading out of the a11y tree, which deletes the reading for exactly the people a colour "
|
|
"cannot tell it to")
|
|
|
|
# The arm is the server's decision, read from the config it serves — never inferred.
|
|
flag_at = js.index("state.writesEnabled = Boolean(gateway.writes_enabled)")
|
|
assert flag_at != -1, "the arm is not the server's decision — a page that infers it can be wrong"
|
|
assert flag_at > js.index("const config = await fetchJSON('/api/config')"), (
|
|
"the flag is set before the config is read, so a fetch failure would not leave the safe arm")
|
|
for field in ("writesEnabled: false", "optimistic: null", "switching: null"):
|
|
assert field in js, f"state lost {field}"
|
|
|
|
# The row's connection, the path the one click listener takes: `buildSwitch()` is bound once
|
|
# (the row is patched in place), so the handler reaches the *current* connection through the
|
|
# row, and `renderLeaf` keeps the map current.
|
|
assert "rowConns.set(li, conn)" in js, (
|
|
"a pruned or repainted row would click the connection it was last painted with")
|
|
|
|
sw = js[js.index("function buildSwitch("):js.index("function switchAria(")]
|
|
assert "document.createElement('button')" in sw, \
|
|
"the switch is not a button, so it has no accessible role to carry the reading"
|
|
assert "button.setAttribute('role', 'switch')" in sw
|
|
assert "gate-switch-thumb" in sw, "the pill lost its knob, so it is no longer the sketch"
|
|
# The two arms: the off arm exits early, and only the code after that exit may be live.
|
|
off_at = sw.index("if (!state.writesEnabled)")
|
|
off_arm = sw[off_at:sw.index("return button")]
|
|
assert "setAttribute('aria-disabled', 'true')" in off_arm, \
|
|
"the off arm lost its `aria-disabled`, so it reads as a usable control"
|
|
assert "button.title = WRITES_OFF_TITLE" in off_arm, \
|
|
"the off arm's tooltip stopped naming the missing write path"
|
|
on_arm = sw[sw.index("return button"):]
|
|
assert "button.addEventListener('click'" in on_arm, \
|
|
"the writes-on arm lost its one click listener — it reads live and does nothing"
|
|
assert "aria-disabled" not in on_arm, \
|
|
"the writes-on arm sets `aria-disabled`, so its focus is refused while it looks ready"
|
|
assert "startSwitch" in on_arm and "rowConns" in on_arm, \
|
|
"the click has no path to the row's connection or to the flow"
|
|
title = re.search(r"button\.title = ([^;]+);", on_arm)
|
|
assert title and "config file" in title.group(1) and "reload" in title.group(1), \
|
|
"the writes-on tooltip no longer names what an activation does"
|
|
assert not re.search(r"\bbutton\.disabled\b|setAttribute\('disabled'", sw), (
|
|
"the switch grew the `disabled` attribute, which drops it out of the a11y tree — the reading "
|
|
"would disappear for a screen reader while looking identical")
|
|
|
|
# The off arm's title is the API's own gate-5 refusal, byte for byte — the switch and the
|
|
# route it would call say the same sentence, in both directions.
|
|
const = re.search(r"const WRITES_OFF_TITLE = '([^']+)'", raw)
|
|
assert const, "the off arm lost its title constant"
|
|
refusal = client.post("/api/gateway/route",
|
|
json={"id": "x", "action": "disable"})
|
|
assert refusal.status_code == 403
|
|
assert const.group(1) == refusal.json()["detail"], (
|
|
f"the switch's title ({const.group(1)!r}) and the API's refusal "
|
|
f"({refusal.json()['detail']!r}) no longer say the same sentence")
|
|
|
|
ric = js[js.index("function routeInConfig("):js.index("const WRITES_OFF_TITLE")]
|
|
assert "return conn.enabled !== false;" in ric, (
|
|
"the switch's state is no longer the config's own word — an off route would read on")
|
|
|
|
# The one state a click may create: the confirmed-when-caught-up optimistic flip.
|
|
opt = js[js.index("function optimisticFor("):js.index("function switchDialog(")]
|
|
assert "optimistic.checked === routeInConfig(conn)" in opt, \
|
|
"a confirmed flip is never cleared — a stale flip would survive a payload that never changed"
|
|
assert "replace(/#off$/, '')" in opt and "#off" in opt, \
|
|
"the flip no longer matches the id's `#off` spelling — a disabled row's switch would never settle"
|
|
|
|
# The flow's ordering is the safety: the dry run before the ask, the word before the send.
|
|
start = js[js.index("async function startSwitch("):js.index("function openSwitchDialog(")]
|
|
assert "state.switching) return" in start, \
|
|
"two switch flows can run at once — a second dialog would confirm the first one's route"
|
|
dry_at = start.index("dry_run: true")
|
|
open_at = start.index("openSwitchDialog")
|
|
assert dry_at != -1 and open_at != -1 and dry_at < open_at, \
|
|
"the click asks for the word before the server has said what the change will do"
|
|
assert "danger" not in start, \
|
|
"the dry run carries the confirm word — the ask is the dialog's, not the dry run's"
|
|
|
|
send = js[js.index("async function sendSwitch("):js.index("function closeSwitchDialog(")]
|
|
word_at = send.index("input.value.trim() !== 'DANGER'")
|
|
fetch_at = send.index("await fetchJSON")
|
|
assert word_at != -1 and word_at < fetch_at, "a wrong word still sends"
|
|
assert "danger: 'DANGER'" in send, "the confirm send lost the typed word"
|
|
assert "aria-busy" in send, "the one in-flight send no longer says it is in flight"
|
|
assert "applied === true" in send and "state.optimistic = { id: pending.id, " in send, \
|
|
"an applied answer no longer flips the switch's own `aria-checked` early"
|
|
assert "pending.action === 'enable'" in send, \
|
|
"the early flip no longer says what the action did — enable and disable would read the same"
|
|
assert "answer.refused" in send and "printAlert" in send, \
|
|
"a refused answer no longer prints the helper's own word verbatim"
|
|
assert "await refresh()" in send, \
|
|
"the row's word is no longer the payload's after the answer — the page is re-reading nothing"
|
|
assert "state.switching = null" in send, "a finished flow is never cleared"
|
|
|
|
# The dialog's wiring: the native `cancel` (Esc) clears the flow, the two buttons by slot.
|
|
boot = js[js.index("document.addEventListener('DOMContentLoaded'"):
|
|
js.index("state.poller = poll(")]
|
|
assert "dialog.addEventListener('cancel'" in boot, "Esc no longer cancels the flow"
|
|
assert "[data-k=\"cancel\"]" in boot and "[data-k=\"send\"]" in boot, \
|
|
"the dialog's buttons are no longer reached by slot"
|
|
|
|
# `aria-checked` is patched from the payload pass, beside the colour that means the same thing —
|
|
# with the one early paint a click may make, from the same `on` the line is coloured from.
|
|
leaf = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
|
assert "const on = routeInConfig(conn)" in leaf, \
|
|
"the switch's state is no longer the value the line is coloured from"
|
|
assert "optimisticFor(conn)" in leaf, \
|
|
"the early flip is no longer confirmed by the payload"
|
|
assert "const switchShown = optimistic !== null ? optimistic : on;" in leaf, \
|
|
"the switch's early flip and the row's paint are no longer one value"
|
|
assert "sw.setAttribute('aria-checked', checked)" in leaf
|
|
assert "sw.setAttribute('aria-label', swAria)" in leaf, \
|
|
"the switch has no accessible name, so it is a pill with a claim it never states"
|
|
assert "activate to switch it off" in leaf and "activate to switch it on" in leaf, \
|
|
"the writes-on name lost the verb — the switch says what the config says and nothing it does"
|
|
assert "state.writesEnabled" in leaf, \
|
|
"the verb is written in both arms — the off arm would promise a click it has no listener for"
|
|
label = js[js.index("function switchAria("):js.index("function leafRow(")]
|
|
for fact in ("endpointText(conn.out)", "selectors", "config", "on ? 'on' : 'off'"):
|
|
assert fact in label, f"the switch's name stopped saying {fact}"
|
|
|
|
# The events this page listens for, as an allow-list rather than a count: `toggle` is the
|
|
# charts disclosure, `DOMContentLoaded` is the boot, the two phase-31 names belong to the
|
|
# filter field and to nothing else, `cancel` is the dialog's native close, and `click` is
|
|
# exactly three — the switch and the dialog's two buttons. The one that changed is what the
|
|
# task is.
|
|
bound = re.findall(r"addEventListener\('(\w+)'", js)
|
|
assert set(bound) <= {"toggle", "DOMContentLoaded", "input", "keydown", "click", "cancel"}, \
|
|
f"the page now binds {sorted(set(bound))} — a listener this test has not named"
|
|
assert bound.count("click") == 3, \
|
|
f"there are {bound.count('click')} click listeners — the switch's and the dialog's two, no more"
|
|
assert bound.count("cancel") == 1, "the dialog's Esc close is bound more than once"
|
|
assert js.index("button.addEventListener('click'") > off_at, \
|
|
"the switch's click listener is not the writes-on arm's"
|
|
filter_body = js[js.index("function initFilter("):js.index("function pruneCards(")]
|
|
for event in ("input", "keydown"):
|
|
assert f"addEventListener('{event}'" in filter_body, (
|
|
f"the filter no longer binds {event} in its own function, so the allow-list above is "
|
|
"hiding a listener somewhere it cannot be read")
|
|
assert js.count(f"addEventListener('{event}'") == 1, \
|
|
f"{event} is bound in more than one place on this page"
|
|
|
|
css = _code(_css(client))
|
|
rules = re.findall(r"([^{}]*\.gate-switch[^{}]*)\{([^}]*)\}", css)
|
|
assert rules, "the switch has no rule at all, so its ink is the browser default"
|
|
hover_rules = []
|
|
for selector, body in rules:
|
|
assert ":enabled" not in selector and ":disabled" not in selector, (
|
|
f"{selector.strip()} keys the switch on its enabled state — the pill would then paint "
|
|
"every one of them red by accident")
|
|
if ":hover" in selector:
|
|
# The one hover the map carries: the writes-on arm's, under the
|
|
# `:not([aria-disabled])` selector only. A hover on a selector the off arm matches
|
|
# would promise a click the arm never honours.
|
|
assert selector.strip() == 'button.gate-switch:not([aria-disabled="true"]):hover', (
|
|
f"a hover on a selector the writes-off arm matches: {selector.strip()}")
|
|
hover_rules.append(selector.strip())
|
|
for token in re.findall(r"var\((--[a-z0-9-]+)\)", body):
|
|
assert re.search(rf"^\s*{token}:", css, re.MULTILINE), \
|
|
f"the switch reads an undeclared colour token: {token}"
|
|
assert hover_rules, "the writes-on arm has no hover — a real control that cannot be felt"
|
|
# The arms' pointers: the off arm's is `not-allowed` (a hover promises nothing, a click does
|
|
# nothing); the on arm's is `pointer` (a real control), with the one hover of the map.
|
|
# Nothing else on the page may advertise a pointer.
|
|
off_rule = _declarations(css, '.gate-switch[aria-disabled="true"]')
|
|
assert off_rule.get("cursor", "") == "not-allowed", \
|
|
"the writes-off arm lost its `not-allowed` pointer"
|
|
on_rule = _declarations(css, ".gate-switch:not([aria-disabled])")
|
|
assert on_rule.get("cursor", "") == "pointer", \
|
|
"the writes-on arm does not advertise a pointer — it reads dead"
|
|
pointer_rules = [sel.strip() for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", css)
|
|
if ("gate-" in sel or ".pipe" in sel) and "cursor: pointer" in body]
|
|
assert all(".gate-switch:not([aria-disabled])" in s for s in pointer_rules), \
|
|
f"`cursor: pointer` left the switch: {pointer_rules}"
|
|
hover = [sel.strip() for sel, body in re.findall(r"([^{}]+)\{([^}]*)\}", css)
|
|
if ":hover" in sel and ("gate-" in sel or ".pipe" in sel)
|
|
and ":not([aria-disabled" not in sel]
|
|
assert not hover, f"a hover affordance appeared on the connection map: {hover}"
|
|
# The pill and its knob take the existence pair, so the gateway owns one colour vocabulary and
|
|
# the soft fill derives from it rather than borrowing a verdict tint.
|
|
assert "var(--gate-on)" in _declarations(css, ".gate-switch").get("border", ""), \
|
|
"the switch's on-state border is not the existence pair"
|
|
assert "var(--gate-off)" in _declarations(
|
|
css, '.gate-switch[aria-checked="false"]').get("border-color", ""), \
|
|
"the off state has no colour, so `[aria-checked]` is decoration"
|
|
section = _code(_css_section(_css(client), "gateway pipes"))
|
|
assert "--ok-soft" not in section and "--fail-soft" not in section, \
|
|
"a gateway rule reached for a verdict tint instead of deriving its fill from the pair"
|
|
|
|
# The dialog's own markup: one `<dialog>`, the labelled title, the slots the flow paints,
|
|
# the one `<pre>` diff, the confirm field, and the alert area.
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
dlg = html[html.index("<dialog"):html.index("</dialog>")]
|
|
assert "gate-switch-dialog" in dlg and 'aria-labelledby="gate-switch-title"' in dlg, \
|
|
"the dialog lost its labelled title"
|
|
for slot_name in ("route", "selectors", "diff", "hint", "input", "alert", "cancel", "send"):
|
|
assert f'data-k="{slot_name}"' in dlg, f"the dialog lost its {slot_name} slot"
|
|
assert "<pre" in dlg, "the diff is no longer a labelled block — a long diff would wrap mid-line"
|
|
assert "placeholder=\"DANGER\"" in dlg, "the confirm field no longer names the word it asks for"
|
|
assert dlg.count("<dialog") == 1 and html.count("<dialog") == 1, \
|
|
"the page grew a second dialog — the one in-flight send is no longer the only one"
|
|
|
|
# …and the page says so, once, in the footer rather than in a tooltip: what the switch does
|
|
# when the flag is set, and what it does when it is not.
|
|
foot = " ".join(html[html.index("<footer"):].split())
|
|
assert "GATEWAY_ALLOW_WRITES" in foot and "does nothing" in foot, \
|
|
"the footer stopped saying what the switch does — and what it does when the flag is set"
|
|
|
|
|
|
|
|
def test_a_toggle_asks_for_a_preview_then_the_word_then_sends_once(client) -> None:
|
|
"""Phase 33 task 06 — the order is the safety, pinned on the delivered script.
|
|
|
|
`dry_run: true` is issued **before** any `DANGER` send (the server's word for what the
|
|
change will do, before the operator is asked for the word), the exact typed word is the
|
|
one gate on the send, and one activation is one send: no retry loop, no second dial. The
|
|
server re-checks everything on its side (gate 10); a client retry would be a second write
|
|
through the fleet's only ingress.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
dry = js.index("dry_run: true")
|
|
danger = js.index("danger: 'DANGER'")
|
|
assert dry != -1 and danger != -1
|
|
assert dry < danger, "a `DANGER` send is issued before the dry run has answered"
|
|
start = js[js.index("async function startSwitch("):js.index("function openSwitchDialog(")]
|
|
assert start.count("dry_run: true") == 1 and "openSwitchDialog" in start, \
|
|
"the preview is no longer the dry run's one job"
|
|
send = js[js.index("async function sendSwitch("):js.index("function closeSwitchDialog(")]
|
|
assert "input.value.trim() !== 'DANGER'" in send, \
|
|
"the send does not require the exact word — a wrong word would reach the helper"
|
|
assert send.count("await fetchJSON") == 1, \
|
|
"the send dials more than once per activation — a retry is a second write"
|
|
for loop in ("while (", "for (", ".retry"):
|
|
assert loop not in send, f"a retry loop inside the send: {loop.strip()}"
|
|
assert "dry_run: false" in send, "the confirm send is not a real send"
|
|
|
|
|
|
def test_the_row_is_painted_from_the_answer_not_the_click(client) -> None:
|
|
"""Phase 33 task 06 — the row is painted from the payload, the switch's one optimistic
|
|
`aria-checked` from the answer, and nothing else.
|
|
|
|
A click may write only the in-flight flag and, on `applied: true`, the one optimistic
|
|
flip (held until the payload catches up). The row's word, class and spine are the
|
|
payload's from the next poll, and `applied: false` touches no `aria-checked` at all —
|
|
a refused answer that painted would be the page claiming a change it did not get.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
# The row's on/off classes have one writer each: `renderLeaf`, reading the payload's own
|
|
# word through `routeInConfig`.
|
|
assert js.count("li.classList.toggle('on'") == 1 and js.count("li.classList.toggle('off'") == 1
|
|
leaf = js[js.index("function renderLeaf("):js.index("function renderTrunk(")]
|
|
assert "routeInConfig(conn)" in leaf, \
|
|
"the row no longer paints from the payload's own word — a click would paint it"
|
|
# The switch's `aria-checked` has one writer — the patched `renderLeaf` path — and its
|
|
# source is the payload's reading or the confirmed optimistic flip, gated on the arm.
|
|
assert js.count("sw.setAttribute('aria-checked'") == 1, \
|
|
"the switch's `aria-checked` has more than one writer"
|
|
assert "state.writesEnabled ? optimisticFor(conn) : null" in leaf, \
|
|
"the switch's flip no longer goes through the optimistic gate"
|
|
# The flip is created in exactly one place (the `applied: true` branch) and cleared in
|
|
# exactly one (when the payload catches up).
|
|
assert js.count("state.optimistic = {") == 1, \
|
|
"the optimistic flip has more than one writer — a refused send could paint the switch"
|
|
assert "optimistic.checked === routeInConfig(conn)" in js, \
|
|
"the optimistic flip is never cleared — a stale flip would outlive the payload"
|
|
# The send itself paints nothing: no row class, no `aria-checked`, no second flip. The
|
|
# refused branch prints the helper's word and refocuses; the paint is the next poll's.
|
|
send = js[js.index("async function sendSwitch("):js.index("function closeSwitchDialog(")]
|
|
assert "classList" not in send, "the send paints a row class — the row is the payload's"
|
|
assert "setAttribute('aria-checked'" not in send, \
|
|
"the send flips the switch itself — only `renderLeaf` may paint `aria-checked`"
|
|
|
|
|
|
def test_the_switch_sentence_matches_the_routes_refusal() -> None:
|
|
"""Phase 33 task 06 — the writes-off title and the API's `GATEWAY_ALLOW_WRITES`
|
|
refusal are the same sentence, read from **both files**.
|
|
|
|
The switch's tooltip (the JS constant) and the route's gate-5 refusal (the `HTTPException`
|
|
in `api.py`) must say one sentence: a UI that tells the operator a different story from
|
|
the endpoint it would call is two sources of truth for one fact. Read from the files,
|
|
not the served response, so the pin holds even when the server is not running.
|
|
"""
|
|
api_src = (Path(__file__).resolve().parents[1] / "src" / "dashboards" / "api.py").read_text()
|
|
sentence = None
|
|
for node in ast.walk(ast.parse(api_src)):
|
|
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "HTTPException":
|
|
for arg in node.args:
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) \
|
|
and "GATEWAY_ALLOW_WRITES" in arg.value and "disabled" in arg.value:
|
|
sentence = arg.value
|
|
assert sentence, "the gate-5 refusal in `api.py` no longer reads as a string literal"
|
|
js = _code((Path(__file__).resolve().parents[1]
|
|
/ "src" / "dashboards" / "web" / "static" / "page-gateway.js").read_text())
|
|
const = re.search(r"const WRITES_OFF_TITLE = '([^']+)'", js)
|
|
assert const, "the off arm lost its title constant"
|
|
assert const.group(1) == sentence, (
|
|
f"the switch's title ({const.group(1)!r}) and the route's refusal ({sentence!r}) "
|
|
"no longer say the same sentence")
|
|
|
|
|
|
def test_the_stream_map_is_rendered_inside_nginx_and_nowhere_else(
|
|
client, gateway_client) -> None:
|
|
"""The `map` is nginx config, so its fan-out is the nginx panel's `:443` leaves and nothing else.
|
|
|
|
Phase 29 drew the stream `map` as a card of its own, which split one file's routes across two
|
|
headings and left the operator to work out that the sixteen domains on one card left through the
|
|
port shown on the other. The payload already says it (`out.kind == "map"`, `source == "nginx"`,
|
|
`in.port == 443`), so the page has no `map` key, no `map_targets` reader, and no third panel.
|
|
|
|
Pinned from both ends: the script ships exactly the two panel definitions and never indexes
|
|
`nginx.map_targets`, and the live document's map connections are all nginx's, all on one port,
|
|
and exactly as many as the map has routing entries (18 on this capture — 17 `*.reeselink.com`
|
|
plus the `default` the file writes for SNI nobody mapped).
|
|
"""
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
definitions = js[js.index("const CARDS = ["):js.index("];", js.index("const CARDS = ["))]
|
|
keys = re.findall(r"key: '(\w+)'", definitions)
|
|
assert keys == list(GATEWAY_CARD_KEYS), (
|
|
f"the script ships {keys}; the stream map must not be a panel key")
|
|
assert "map_targets" not in js, (
|
|
"the page reads the map's target list again — its fan-out arrives as connections[] now")
|
|
assert 'data-gate="map"' not in html
|
|
|
|
payload = gateway_client.get("/api/gateway/snapshot").json()
|
|
mapped = [row for row in payload["connections"] if row["out"]["kind"] == "map"]
|
|
assert mapped, "the captured stream config routes nothing, so this pin proves nothing"
|
|
assert {row["source"] for row in mapped} == {"nginx"}, (
|
|
"a `map` route is filed under a config family other than nginx")
|
|
assert {row["in"]["port"] for row in mapped} == {443}, (
|
|
"the map's leaves hang off a port that is not the stream server's `listen`")
|
|
assert len(mapped) == len(payload["nginx"]["map_targets"]), (
|
|
"the leaves the nginx panel draws are not the map's routing entries — one of the two lists "
|
|
"is dropping an entry")
|
|
selectors = {sel for row in mapped for sel in row["out"]["selectors"]}
|
|
assert "default" in selectors, (
|
|
"the map's own word for unmapped SNI was paraphrased away instead of printed verbatim")
|
|
|
|
|
|
def test_a_listener_with_no_declared_target_says_so_instead_of_drawing_nothing(
|
|
client, gateway_client) -> None:
|
|
"""A trunk that routes nowhere is a fact, not a gap in the drawing.
|
|
|
|
This fleet's nginx `:80` vhost is a `return 301`: it listens, it names `_`, and it writes no
|
|
`proxy_pass`, so the graph has a listener for it and no connection. Dropping the group would turn
|
|
"this port serves no target" into "this port does not exist" — the same refusal `sources/gateway.py`
|
|
keeps for the warning list — so the leaf list renders one faint sentence instead of nothing, and
|
|
the sentence comes from the script (the payload publishes no row to render).
|
|
"""
|
|
payload = gateway_client.get("/api/gateway/snapshot").json()
|
|
untargeted = [row["port"] for row in payload["listeners"]
|
|
if not any(conn["in"]["port"] == row["port"] and conn["source"] == row["source"]
|
|
for conn in payload["connections"])]
|
|
assert 80 in untargeted, (
|
|
f"the capture's :80 vhost is no longer the live case this pin is written against: "
|
|
f"{untargeted}")
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
groups = js[js.index("function groupsFor("):js.index("function endpointText(")]
|
|
assert "groupFor(row.port).listener = row" in groups, (
|
|
"a listener no connection names no longer produces a group")
|
|
assert not re.search(r"\.filter\([^)]*connections\.length", groups), (
|
|
"groups with no leaves are being dropped, which is the silence this test exists to stop")
|
|
leaves = js[js.index("function renderLeaves("):js.index("function renderGroups(")]
|
|
assert "no target named in the config" in js, "the refusal sentence is gone"
|
|
assert "group.connections.length === 0" in leaves, (
|
|
"the empty leaf list no longer renders the sentence instead of nothing")
|
|
|
|
|
|
def test_a_fan_out_is_patched_and_reordered_never_rebuilt(client) -> None:
|
|
"""Seventeen rows off one trunk are built once and then patched, in payload order.
|
|
|
|
The slot-grid discipline (`/llm`): rebuilding a row on every 30 s poll re-announces it to
|
|
assistive tech for a difference of one connection, and a 17-row fan-out makes that audible. So
|
|
groups are keyed by port and rows by the connection's own `id` in per-panel `Map`s, an existing
|
|
node is moved with `appendChild` rather than re-created, and pruning happens once per panel by
|
|
id — not by emptying the container, which is the rebuild in slower motion.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
groups = js[js.index("function renderGroups("):js.index("function familyWord(")]
|
|
leaves = js[js.index("function renderLeaves("):js.index("function renderGroups(")]
|
|
assert "state.groups" in groups and "state.rows" in groups
|
|
for name, body in (("group", groups), ("leaf", leaves)):
|
|
assert "appendChild" in body, (
|
|
f"the {name} renderer can no longer reorder the rows it already built")
|
|
assert "built.get(id)" in groups and "let el = built.get(id)" in groups, (
|
|
"a group is no longer keyed on the element the last poll built")
|
|
assert "let li = rows.get(id)" in leaves, (
|
|
"a leaf row is no longer keyed on the element the last poll built")
|
|
assert ".remove()" in groups, "the panel never prunes the rows and groups that left the payload"
|
|
assert "box.innerHTML = '';" not in groups, (
|
|
"a panel's groups are wiped every poll — that is a rebuild, and it re-announces every row")
|
|
# …and the wipe that IS allowed: a panel whose whole family left the payload.
|
|
prune = js[js.index("function pruneCards("):js.index("document.addEventListener(")]
|
|
assert "box.innerHTML = ''" in prune and "state.groups.delete" in prune
|
|
|
|
|
|
def test_warnings_render_in_the_overviews_alert_vocabulary_and_hide_when_clean(
|
|
client, gateway_client) -> None:
|
|
"""The config-integrity list, both directions, on the shape the overview already uses.
|
|
|
|
`/gateway`'s warnings are `sources/gateway.warnings()`' rows, rendered as `li.warn` with a
|
|
`.src` (the config family) and a `.msg` (the row's own sentence) — the markup `page-index.js`
|
|
uses for an alert, because "a backend no frontend routes to" is the same kind of sentence and
|
|
should not grow a second look. The page reads `kind` and `message` and invents nothing.
|
|
|
|
Both directions are pinned because the absence is half the claim: the captured ingress is clean
|
|
(`warnings == []`) and the panel must stay `hidden`, and one real row from the source must make
|
|
it appear with that row's words, four fields and all.
|
|
"""
|
|
html = _code(client.get(GATEWAY_PAGE).text)
|
|
panel = _warnings_section(html)
|
|
assert "hidden" in panel[:panel.index(">")]
|
|
assert '<ul class="alerts" id="warnings-list">' in panel
|
|
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
body = js[js.index("function renderWarnings("):js.index("function dotFor(")]
|
|
assert "li.className = 'warn'" in body, "the row is not the overview's `.warn` row"
|
|
assert "src" in body and "msg" in body, "the row lost the overview's two spans"
|
|
assert "snapshot.warnings" in body, "the page derives a warning instead of rendering one"
|
|
assert "warning.message" in body and "familyWord(warning.kind)" in body
|
|
assert "hidden = rows.length === 0" in body, "an empty list no longer hides the panel"
|
|
assert "innerHTML" not in body and "escapeHtml" not in body, \
|
|
"the warnings rows interpolate upstream text into markup"
|
|
|
|
clean = gateway_client.get("/api/gateway/snapshot").json()
|
|
assert clean["warnings"] == [], "the captured configs are no longer clean — this arm changed"
|
|
|
|
row = one_real_warning()[0]
|
|
job = gateway_client.app.state.ctx.hub.jobs["gateway"]
|
|
assert job.state.payload is not None and job.state.payload["warnings"] == []
|
|
job.state.payload["warnings"] = [row]
|
|
served = gateway_client.get("/api/gateway/snapshot").json()["warnings"]
|
|
assert served == [row], "the route does not serve the warning the page would render"
|
|
assert {"id", "kind", "subject", "message"} <= set(served[0])
|
|
assert served[0]["kind"].startswith("haproxy"), (
|
|
"the row the page files under a config family is not filed by one")
|
|
job.state.payload["warnings"] = []
|
|
|
|
|
|
def test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero(client, gateway_client) -> None:
|
|
"""A leaf the join cannot count prints `—`, and the tooltip says **which** unknown it is.
|
|
|
|
`/proc` reports addresses, so a route whose far end has no address cannot be counted, and `0` would
|
|
be a claim about traffic (rule 2) while a column of `n/a` would be twelve identical words
|
|
competing with the page's one footnote — so the leaf prints an em dash whose `title` names the
|
|
reason, while the trunk keeps `n/a`, which is a different fact: the socket table itself went quiet.
|
|
Phase 32 task 04 sharpened the mechanism and not only the wording: the dash now has **two** causes
|
|
that `countCause` separates from `out.address` / `out.resolved` — a name the gateway could not
|
|
resolve, and an address that is known beside a table that answered nothing — and the sentence is
|
|
chosen from data, never guessed. A `0` is neither of them: it is a measurement.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
head = js[:js.index("function countText(")]
|
|
assert "const NO_COUNT = '—'" in head, "the unattributed leaf count is no longer an em dash"
|
|
assert "the gateway could not resolve this name in this reading" in head, \
|
|
"the dash stopped explaining itself"
|
|
assert "function countCause(" in head and "out.address === true" in head \
|
|
and "out.resolved" in head, \
|
|
"the dash's cause is no longer read from the two payload fields that decide it"
|
|
assert "nothing here resolves names" not in head, (
|
|
"the old sentence blamed the name, which phase 32 task 03 made a false reason")
|
|
leaf = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
|
assert "shown == null ? NO_COUNT : shown" in leaf, "a null count is not the dash any more"
|
|
assert "count.title = shown == null ? countWhy(conn) : countTitle(conn, shown)" in leaf, \
|
|
"the dash lost its sentence, or a counted line lost the provenance beside its number"
|
|
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
|
assert "live" in trunk and "n/a" not in trunk, (
|
|
"the trunk now prints the leaf's dash too — a quiet socket table is a different fact, and "
|
|
"setTxt turns a null into `n/a` on its own")
|
|
assert not re.search(r"connections\s*\|\|\s*0", js), "an uncounted endpoint became a claimed 0"
|
|
|
|
payload = gateway_client.get("/api/gateway/snapshot").json()
|
|
unattributed = [row for row in payload["connections"] if row["connections"] is None]
|
|
assert unattributed, "the captured join counted everything, so this arm has nothing to prove"
|
|
assert all(row["out"]["address"] is False for row in unattributed), (
|
|
"an endpoint the config wrote as an address came back with no count — that is the socket "
|
|
"table's arm, not the name arm, and it prints a different sentence")
|
|
|
|
|
|
def test_an_unresolved_name_and_an_idle_route_say_two_different_sentences(
|
|
client, gateway_snapshot: Any) -> None:
|
|
"""The em dash and the measured `0` are two facts, and every surface of the row says so.
|
|
|
|
Phase 32 task 03 made a name-written route countable, which left the page's one dash sentence
|
|
explaining two different things: a name the gateway could not resolve, and a route that **was**
|
|
resolved and simply has nothing open. Task 04 splits them, and the split has to be on every
|
|
surface a reader can meet — the glyph, the `title`, the `aria-label` — because a `0` read as a
|
|
missing measurement (or a dash read as an idle route) sends the operator to the wrong box.
|
|
|
|
Three arms, all from the recorded capture and one injection, all rendered through
|
|
`count_surfaces` (the mirror) with the served-byte pins below as its other half:
|
|
|
|
* **resolved and idle** — `brain.reeseapps.com` reaches
|
|
`reeseapps-brain-of-reese.reeselink.com:443`, the gateway resolved it to `10.1.2.64`, and the
|
|
kernel reports no socket: the glyph is `0`, and a `0` is a measurement.
|
|
* **resolved and busy** — the ntfy route, whose glyph is its own number.
|
|
* **not resolved** — one entry of the recorded document emptied and marked `nxdomain`, exactly
|
|
what agent 1.3.0 publishes for a name with no record: the glyph is the em dash and the sentence
|
|
is the resolution one, never the socket one.
|
|
|
|
And a fourth, because the third arm's sentence must not become a catch-all: with the kernel's
|
|
table unreadable, a line whose address the **config wrote** is uncounted too, and blaming
|
|
resolution there would be false — so it gets the socket-table sentence.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
head = js[: js.index("function countText(")]
|
|
|
|
# (1) the two sentences exist, differ, name no `n/a`, and neither is the other's wording.
|
|
assert "const WHY_UNRESOLVED = 'the gateway could not resolve this name in this reading'" in head
|
|
assert "const WHY_NO_SOCKETS = 'the socket table did not answer this reading'" in head
|
|
assert COUNT_WHY_UNRESOLVED != COUNT_WHY_NO_SOCKETS
|
|
for sentence in (COUNT_WHY_UNRESOLVED, COUNT_WHY_NO_SOCKETS):
|
|
assert "n/a" not in sentence, "an unknown that has a cause stopped naming its cause"
|
|
# The choice is data: the same two fields `sources/gateway.py` joins on, and nothing else.
|
|
chooser = js[js.index("function countCause("):js.index("function countWhy(")]
|
|
assert "out.address === true" in chooser and "out.resolved" in chooser, \
|
|
"the dash's cause is no longer chosen from the address the config wrote or the gateway found"
|
|
assert "countCause(conn)" in js[js.index("function leafAria("):js.index("function leafTitle(")], \
|
|
"the `aria-label` no longer carries the cause the tooltip carries"
|
|
|
|
# (2) the three arms, rendered.
|
|
resolved = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
|
|
|
def route(host: str, port: int) -> dict[str, Any]:
|
|
return next(row for row in resolved["connections"]
|
|
if row["out"]["host"] == host and row["in"]["port"] == port)
|
|
|
|
idle = count_surfaces(route("reeseapps-brain-of-reese.reeselink.com", 443))
|
|
busy = count_surfaces(route("reeseapps-ntfy.reeselink.com", 443))
|
|
assert idle["glyph"] == "0", "a resolved endpoint with nothing open is a measurement, not a gap"
|
|
assert busy["glyph"] == "3" and busy["why"] is None
|
|
assert idle["title"].startswith("0 open connection(s) on this endpoint") \
|
|
and busy["title"].startswith("3 open connection(s) on this endpoint")
|
|
|
|
broken = gateway_snapshot("resolved")
|
|
for entry in broken["resolver"]["entries"]:
|
|
if entry["name"] == "reeseapps-immich.reeselink.com":
|
|
entry["addresses"] = []
|
|
entry["status"] = "nxdomain"
|
|
unplaced = gateway_source.parse(broken, "gateway")
|
|
dash = count_surfaces(next(row for row in unplaced["connections"]
|
|
if row["out"]["host"] == "reeseapps-immich.reeselink.com"))
|
|
assert dash["glyph"] == "\u2014", "a name with no record grew someone else's number"
|
|
assert dash["why"] == COUNT_WHY_UNRESOLVED and dash["title"] == f"no number: {dash['why']}"
|
|
assert dash["aria"] == f"connections not attributed: {COUNT_WHY_UNRESOLVED}"
|
|
|
|
# (3) the fourth arm: an address the config wrote, beside a kernel that said nothing.
|
|
quiet = gateway_source.parse(gateway_snapshot("sockets_silent"), "gateway")
|
|
unwritten = count_surfaces(next(row for row in quiet["connections"]
|
|
if row["out"]["address"] is True))
|
|
assert unwritten["glyph"] == "\u2014" and unwritten["why"] == COUNT_WHY_NO_SOCKETS, (
|
|
"a line whose address the config wrote was told to go resolve something — the socket table "
|
|
"is what went quiet, and that is the sentence the trunk already prints as `n/a`")
|
|
|
|
# (4) five lines read aloud: distinct claims, no invented number, no `null` leaking as text.
|
|
spoken = [count_surfaces(row) for row in resolved["connections"][:5]]
|
|
claims = {entry["title"] for entry in spoken} | {entry["aria"] for entry in spoken}
|
|
assert len(claims) > 1, "five lines said one thing"
|
|
for entry in [*spoken, idle, busy, dash, unwritten]:
|
|
for text in (entry["glyph"], entry["title"] or "", entry["aria"]):
|
|
assert "undefined" not in text and "NaN" not in text and "null" not in text, \
|
|
f"a row printed {text!r}"
|
|
|
|
|
|
def test_a_counted_line_names_where_its_address_came_from(client, gateway_snapshot: Any,
|
|
resolved_gateway_client) -> None:
|
|
"""Every counted line says, on hover, which address it was counted to and when that was learned.
|
|
|
|
A number that arrived through the gateway's own `libc` resolution is a different claim from one
|
|
the config wrote as an address, and the tooltip is the one place both can say it without growing
|
|
a column of ink per row. Three arms, one rule: the provenance is the payload's (`out.via`,
|
|
`out.resolved_ts`) copied, never paraphrased, and a field that is not there prints **nothing**
|
|
rather than `n/a` or `NaN`.
|
|
|
|
* a **resolved name** — `… · address from libc`, and `, resolved <age>` while the entry has a
|
|
timestamp (the age is `Dash.duration(Dash.ageSeconds(…))`, so its text is a clock and only its
|
|
presence is pinned here);
|
|
* the same row with `resolved_ts` removed — the age fragment goes, the rest of the sentence
|
|
stays, and nothing prints the shared `n/a` where an age belongs;
|
|
* an **address the config wrote** — the bare sentence. It was never resolved, and stamping the
|
|
agent's `literal` echo of it as a lookup would be rule 2 with a tooltip's clothes on.
|
|
"""
|
|
js = _code(client.get("/static/page-gateway.js").text)
|
|
builder = js[js.index("function countTitle("):js.index("const NO_TARGET")]
|
|
for field in ("out.via", "out.resolved", "out.resolved_ts"):
|
|
assert field in builder, f"the counted line's tooltip stopped reading {field}"
|
|
for helper in ("duration(", "ageSeconds("):
|
|
assert helper in builder, f"the age stopped going through the shared helper {helper}"
|
|
assert "out.resolved_ts == null" in builder, \
|
|
"a missing timestamp now prints whatever duration() makes of Infinity"
|
|
assert "address from" in builder and "resolved " in builder
|
|
assert "n/a" not in builder and "\u2014" not in builder, \
|
|
"the counted tooltip grew a fallback glyph; a missing fragment is silence, not a mark"
|
|
|
|
resolved = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
|
brain = next(row for row in resolved["connections"]
|
|
if row["out"]["host"] == "reeseapps-brain-of-reese.reeselink.com")
|
|
printed = count_surfaces(brain)
|
|
assert printed["title"] == "0 open connection(s) on this endpoint \u00b7 address from libc"
|
|
assert printed["age"] is True
|
|
served = client.get("/static/page-gateway.js").text
|
|
assert "\u00b7 address from ${via}" in served and ", resolved ${age}`" in served, \
|
|
"the two provenance fragments are no longer composed from the payload's own words"
|
|
|
|
aged = dict(brain, out=dict(brain["out"], resolved_ts=None))
|
|
lost = count_surfaces(aged)
|
|
assert lost["title"] == printed["title"], "losing the timestamp lost the address it came from"
|
|
assert lost["age"] is False, "an age was printed for a row that publishes no timestamp"
|
|
assert "NaN" not in lost["title"] and "n/a" not in lost["title"]
|
|
|
|
written = next(row for row in resolved["connections"] if row["out"]["address"] is True)
|
|
plain = count_surfaces(written)
|
|
assert plain["title"] == f"{written['connections']} open connection(s) on this endpoint", \
|
|
"an address the config wrote was given a resolution provenance it never had"
|
|
assert "address from" not in plain["title"]
|
|
|
|
# …and the field table, in **both** directions (the `SLOT_FIELDS` device): the three names the
|
|
# tooltip and the filter index are named there, and the 1.3.0 document answers each with real
|
|
# data, so a rename in `sources/gateway.py` fails by name here instead of printing a tooltip that
|
|
# quietly stopped carrying its provenance.
|
|
provenance = ("connections.out.resolved", "connections.out.via",
|
|
"connections.out.resolved_ts")
|
|
live = resolved_gateway_client.get("/api/gateway/snapshot").json()
|
|
for path in provenance:
|
|
assert path in GATEWAY_PAGE_FIELDS, (
|
|
f"the counted line reads {path.removeprefix('connections.')}, which the page's field "
|
|
"table does not name")
|
|
found, values = _payload_field(live, path)
|
|
assert found and any(value is not None for value in values), (
|
|
f"{path} is named but the resolved document never answers it, so the pin above would "
|
|
"pass over a payload that carries nothing")
|
|
assert any(value is None for path in provenance
|
|
for value in _payload_field(live, path)[1]), (
|
|
"every row published provenance, including the addresses the config wrote — the arm that "
|
|
"must print nothing is no longer distinguishable")
|
|
|
|
|
|
def test_the_docs_name_the_page_its_knob_and_its_gauges() -> None:
|
|
"""The phase's own completion criterion, pinned so it cannot rot quietly.
|
|
|
|
A page an operator cannot find in the two documents they read is a page that does not exist, and
|
|
a knob named only in `config.py` is edited in the wrong place. Three names have to be readable
|
|
in prose: the route (both page tables), `GATEWAY_HOST` **as a name and not a URL** with its
|
|
default, and the two gauge families the history API is keyed by. The agent-version requirement
|
|
belongs in the page row too — `/gateway` is unconfigured-looking rather than red when the host
|
|
runs a pre-1.2.0 agent, and that sentence is what saves the support call.
|
|
"""
|
|
root = Path(__file__).resolve().parents[1]
|
|
agents = (root / "AGENTS.md").read_text()
|
|
readme = (root / "README.md").read_text()
|
|
|
|
for name, text in (("AGENTS.md", agents), ("README.md", readme)):
|
|
row = next((line for line in text.splitlines() if line.startswith("| `/gateway`")), "")
|
|
assert row, f"{name}'s page table has no `/gateway` row"
|
|
assert "GATEWAY_HOST" in row, f"{name}'s page row never names the knob the page is gated on"
|
|
assert "1.2.0" in row, f"{name}'s page row never names the agent version the page needs"
|
|
assert "1.4.0" in row, f"{name}'s page row never names the agent version the marker needs"
|
|
assert "GATEWAY_HOST" in agents and "default `gateway`" in agents, \
|
|
"the config.py bullet stopped saying what the knob defaults to"
|
|
|
|
# The metric-naming paragraph: these are the names `/api/gateway/history` and `sample.source`
|
|
# speak, and the outbound one carries its port because two services on one host are two facts.
|
|
assert "gateway.in_conns@<listen-port>" in agents, "the inbound gauge left the naming paragraph"
|
|
assert "gateway.out_conns@<host>:<port>" in agents, "the outbound gauge left the naming paragraph"
|
|
assert "/api/gateway/*" in readme, \
|
|
"README stopped documenting the page's own pair of routes"
|
|
|
|
|
|
# ---- the `/disks` page (phase 09 task 04) ---------------------------------- #
|
|
#
|
|
# The fourth dashboard, asserted the way the other three are: on the served bytes, because a
|
|
# JS test runner would be a build step (a locked anchor). What is pinned is not that the page
|
|
# exists but that it keeps the four promises the phase wrote down — one card per **physical**
|
|
# drive and never one per partition, alarm chips that exist only while a counter is non-zero,
|
|
# a host without `smartctl` dimmed rather than reddened, and charts that belong to one host,
|
|
# stay collapsed until opened, and cost one history request per host per refresh.
|
|
|
|
def _disks_js(client: TestClient) -> str:
|
|
return client.get("/static/page-disks.js").text
|
|
|
|
|
|
#: The tab nav's whole content, in order: the six dashboards and nothing else. Phase 29 task 05
|
|
#: appended `/gateway` rather than slotting it in, so the five pre-existing tabs keep their order
|
|
#: and the new one is last — the assertion below compares this list to the nav's hrefs, so the
|
|
#: shape of the nav is one document rather than five separate "is X present" greps.
|
|
TAB_ORDER = list(PAGES)
|
|
|
|
|
|
def _nav_tabs(html: str) -> str:
|
|
"""The served page's `<nav class="tabs">` block, masthead slice included.
|
|
|
|
A grep for `href="/gateway"` anywhere in the document would be satisfied by a footer link; the
|
|
tab table is about the nav, so this is the window every nav pin reads.
|
|
"""
|
|
header = _masthead(html)
|
|
start = header.index('id="nav-tabs"')
|
|
return header[start:header.index("</nav>", start)]
|
|
|
|
|
|
def test_the_disks_tab_is_in_the_masthead_of_every_page(client) -> None:
|
|
"""Five tabs became six, on every page, with exactly one marking itself current.
|
|
|
|
The masthead is the app's only "which dashboard do I need" surface since phase 02 deleted
|
|
the overview's table, so a tab missing from one page is a page you cannot get to, and a
|
|
tab marked current on the wrong page is a lie about where you are.
|
|
|
|
Phase 29 task 05 made this the whole tab audit, in both directions. The loop is over
|
|
`MARKUP_PAGES` (all eight signed-in pages), the nav's hrefs must equal `TAB_ORDER` exactly —
|
|
a tab that leaves the nav is unreachable and a tab that joins it outside the table is a link
|
|
the phase pins know nothing about — and the `aria-current` count is the page's own status:
|
|
one, naming itself, on the six dashboards; zero on `/settings` and `/certs`, which are pages
|
|
and not dashboards. So a page that grew a current tab is caught here, and so is `/gateway`
|
|
shipping the tab everywhere except on itself.
|
|
"""
|
|
for path in MARKUP_PAGES:
|
|
html = client.get(path).text
|
|
nav = _nav_tabs(html)
|
|
assert [m.group(1) for m in re.finditer(r'<a href="([^"]+)"', nav)] == TAB_ORDER, (
|
|
f"{path}'s tab nav is not the six dashboards in order: {nav}")
|
|
|
|
current = re.findall(r'<a href="([^"]+)"[^>]*aria-current="page"', nav)
|
|
expected = [path] if path in PAGES else []
|
|
assert current == expected, (
|
|
f"{path} marks {current} current; a dashboard marks itself and an admin page "
|
|
f"marks nothing (it is not one of the tabs)")
|
|
assert (nav.index('href="/system"') < nav.index('href="/disks"')
|
|
< nav.index('href="/llm"') < nav.index('href="/ups"')
|
|
< nav.index('href="/gateway"')), (
|
|
"tab order is machine, drive, engine, power, ingress")
|
|
|
|
|
|
# ---- the collapsing masthead nav (phase 18) -------------------------------- #
|
|
#
|
|
# At ≤640px the five tab links leave the bar and come back as a dropdown under it, opened by
|
|
# one `☰` button. There is no JS test runner (a locked anchor), so the phase is pinned the way
|
|
# every page surface here is: on the served bytes. Three questions, in the order the pieces
|
|
# were built — is the button on every tabbed page and on no others, does the stylesheet still
|
|
# hide it above the breakpoint, and do the stylesheet and the binding still agree on the
|
|
# breakpoint itself? That last one is the only failure mode no browser check catches: a number
|
|
# that drifts in one file opens a menu into a hidden nav on the operator's phone.
|
|
|
|
#: The attributes that make `#nav-toggle` a button and not a mystery glyph: it is not a form
|
|
#: control (`type`), it says what it toggles (`aria-controls`), it says what state it is in
|
|
#: (`aria-expanded`, which `app.js` keeps honest), and its only content is a `☰`.
|
|
NAV_TOGGLE_ATTRS = ('class="ghost nav-toggle"', 'type="button"', 'aria-expanded="false"',
|
|
'aria-controls="nav-tabs"', 'aria-label="Toggle navigation"')
|
|
|
|
|
|
def _masthead(html: str) -> str:
|
|
"""The served page's own masthead — the block the toggle and the nav have to live in.
|
|
|
|
Slicing rather than searching the whole document is the point: a button in the footer would
|
|
satisfy `id="nav-toggle" in html` and anchor to nothing, because the dropdown's
|
|
`top: 100%` resolves against `.masthead`.
|
|
"""
|
|
start = html.index('<header class="masthead"')
|
|
return html[start:html.index("</header>", start)]
|
|
|
|
|
|
def test_the_nav_toggle_is_in_the_masthead_of_every_tabbed_page(client) -> None:
|
|
"""Eight pages, one identical button, and the nav it controls still carrying six tabs.
|
|
|
|
The dropdown is the only way to change dashboard on a phone, so a page missing the button is
|
|
a page you can enter and leave the tab bar on, and a page whose nav lost its `id` is a button
|
|
that toggles nothing. Pinned on all eight (`MARKUP_PAGES`) rather than on `PAGES` because the
|
|
two admin pages have the same masthead and therefore the same contract.
|
|
"""
|
|
for path in MARKUP_PAGES:
|
|
header = _masthead(client.get(path).text)
|
|
button = re.search(r'<button[^>]*id="nav-toggle"[^>]*>', header, re.S)
|
|
assert button, f"{path}'s masthead has no #nav-toggle button"
|
|
for attr in NAV_TOGGLE_ATTRS:
|
|
assert attr in button.group(0), f"{path}'s nav toggle is missing {attr}"
|
|
assert header.count('id="nav-toggle"') == 1, f"{path} has more than one nav toggle"
|
|
|
|
# The nav is the menu: the id the button names, and all six dashboards inside it.
|
|
assert '<nav class="tabs" id="nav-tabs" aria-label="Dashboards">' in header, \
|
|
f"{path}'s tab nav has no #nav-tabs for the toggle to control"
|
|
nav = header[header.index('id="nav-tabs"'):header.index("</nav>",
|
|
header.index('id="nav-tabs"'))]
|
|
for tab in PAGES:
|
|
assert f'href="{tab}"' in nav, f"{path} collapsed a nav that lost the {tab} tab"
|
|
|
|
# Order inside the masthead: brand, toggle, nav — and the cluster after them, because
|
|
# the ≤640px block right-aligns the button and wraps the cluster onto its own row.
|
|
assert (header.index('id="nav-toggle"') < header.index('id="nav-tabs"')
|
|
< header.index('class="masthead-right"')), \
|
|
f"{path} put the toggle or the nav outside the brand → tabs → cluster row"
|
|
|
|
|
|
def test_the_login_page_carries_no_nav_and_no_toggle(tmp_path: Path) -> None:
|
|
"""`/login` is brand link + theme toggle, and the shared binding must be inert there.
|
|
|
|
No `client` fixture, and that is the whole shape of the test: the signed-in one cannot see
|
|
this page at all (a signed-in `GET /login` is a `303` to the dashboard, and following it
|
|
audits `/`'s markup under the name `/login`), which is the same redirect trap
|
|
`tests/test_auth_api.py::test_the_login_page_carries_no_id_nothing_reads_and_no_dashboard_nav`
|
|
documents for the orphan-`id` audit.
|
|
|
|
Every tab on this page bounces straight back to it, so the page renders no tabs at all; a
|
|
stray hamburger would be a control that opens a menu of redirects, and the `640px` block
|
|
would reveal it. The last assertion is the other half of the contract: `app.js` still loads
|
|
here, which is only safe because `if (navToggle && appNav)` no-ops without the elements.
|
|
"""
|
|
db = str(tmp_path / "login-nav.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
try:
|
|
with TestClient(app) as anon:
|
|
response = anon.get("/login")
|
|
assert response.status_code == 200, \
|
|
"a signed-out /login must be the form, not a bounce"
|
|
html = response.text
|
|
finally:
|
|
store.close()
|
|
|
|
for absent in ('id="nav-toggle"', 'id="nav-tabs"', 'class="tabs"',
|
|
'aria-controls="nav-tabs"', 'aria-label="Dashboards"'):
|
|
assert absent not in html, f"/login renders the collapsed nav's {absent}"
|
|
assert '<a class="brand" href="/">' in html and 'id="theme-toggle"' in html, \
|
|
"/login lost the two pieces of chrome that do belong on it"
|
|
assert 'src="/static/app.js"' in html, (
|
|
"/login stopped loading the shared chrome whose guard is its no-toggle contract")
|
|
|
|
|
|
# ---- the fleet badges on every masthead (phase 25 task 01) ------------------ #
|
|
#
|
|
# `TODO.md:3-4` — "hosts disks engines UPS disappear when in the certs/account page" and the
|
|
# operator's correction: "I did want hosts disks engines UPS badges". `Dash.initChrome()`
|
|
# builds the five badges whenever `#nav-status` exists, so the **container** is the whole
|
|
# difference between a page that has them and a page that does not — the two admin pages shipped
|
|
# without one, which is why they read as the badges vanishing rather than as two odd pages. The
|
|
# fix is the container and nothing else, so the pin is the container: on every page that renders
|
|
# the masthead, in the position the six dashboards already used (first child of
|
|
# `.masthead-right`, which is what puts it on its own row — task 02 re-justifies that row and this
|
|
# pin deliberately says nothing about which way the badges face). `tests/test_auth_api.py` keeps
|
|
# its own assertion that `/login` has none: a stranger has no fleet to be told about.
|
|
|
|
#: The container exactly as all eight signed-in pages write it (`index.html` is the reference).
|
|
NAV_STATUS_ELEMENT = '<div id="nav-status" class="gap" aria-live="polite"></div>'
|
|
|
|
#: The badges `initChrome()` renders, in its own order. `gateway` is the fifth and the phase 29
|
|
#: one: the ingress has a job of its own, so it gets a dot of its own, and `refreshStatus` leaves
|
|
#: it grey when `GATEWAY_HOST` names no target (an unconfigured source is not a fault — invariant
|
|
#: 3 — so a red dot here would train the operator to ignore red).
|
|
NAV_STATUS_BADGES = ("hosts", "disks", "engines", "UPS", "gateway")
|
|
|
|
|
|
def test_every_signed_in_page_carries_the_fleet_badges(client) -> None:
|
|
"""`#nav-status` is the first child of `.masthead-right` on all eight signed-in pages.
|
|
|
|
One loop over `MARKUP_PAGES`, because the control is the same on all of them: the six
|
|
dashboards had it, `/certs` and `/settings` grew it. The `app.js` half of the assertion is
|
|
what makes the markup mean something — the badges are built from the container's existence,
|
|
so a script that stopped reading `#nav-status` would turn eight `<div>`s into dead markup and
|
|
the orphan-`id` audit alone would not say so (it also matches `#nav-status` inside the CSS).
|
|
"""
|
|
js = _code(client.get("/static/app.js").text)
|
|
assert "const nav = $('#nav-status');" in js, \
|
|
"initChrome() no longer builds the badges from #nav-status, so the container is inert"
|
|
assert "if (nav) {" in js, \
|
|
"the container guard is gone — a page without #nav-status (only /login) would throw"
|
|
for label in NAV_STATUS_BADGES:
|
|
assert f"label: '{label}'" in js, f"the badge table lost {label}"
|
|
assert "setInterval(refreshStatus, 15000)" in js, \
|
|
"the badges' refresh cadence moved — this phase changes placement, not polling"
|
|
|
|
for path in MARKUP_PAGES:
|
|
header = _masthead(client.get(path).text)
|
|
assert NAV_STATUS_ELEMENT in header, \
|
|
f"{path} has no fleet badges in its masthead (TODO.md:3)"
|
|
after_cluster = header[header.index('<div class="masthead-right">')
|
|
+ len('<div class="masthead-right">'):]
|
|
assert after_cluster.lstrip().startswith(NAV_STATUS_ELEMENT), \
|
|
f"{path} does not render #nav-status as the first child of .masthead-right "\
|
|
"(the six dashboards' position is what puts the badges on their own row)"
|
|
assert header.count('id="nav-status"') == 1, f"{path} renders the badge container twice"
|
|
|
|
|
|
def test_the_nav_toggle_css_and_binding_carry_the_same_breakpoint(client) -> None:
|
|
"""Hidden by default, revealed at one breakpoint, in two files that must agree on its value.
|
|
|
|
The base rule is *not* a literal `.nav-toggle { display: none }` and pinning that literal
|
|
would be pinning a bug: the element also carries the shared `ghost` class, whose
|
|
`button.ghost { display: inline-flex }` out-ranks a single-class rule and ships a visible
|
|
button into the desktop masthead. The shipped shape is an id rule reading a token
|
|
(`--nav-toggle-display`, `none` in `:root`), which the ≤640px block flips by re-declaring the
|
|
token on the element — so that is what is asserted here, plus the dropdown and the four close
|
|
paths the binding owns.
|
|
"""
|
|
css = _code(_css(client))
|
|
assert re.search(r"--nav-toggle-display:\s*none", css), \
|
|
"the nav toggle is no longer hidden by default — the desktop masthead grew a ☰"
|
|
assert re.search(r"#nav-toggle\s*\{\s*display:\s*var\(--nav-toggle-display\)", css), \
|
|
"#nav-toggle stopped reading the token that keeps its reveal order-independent"
|
|
|
|
nav_css = _code(_css_section(_css(client), "mobile nav"))
|
|
media = re.search(r"@media\s*\(max-width:\s*(\d+)px\)", nav_css)
|
|
assert media, "the mobile nav block lost its media query"
|
|
for rule in ("--nav-toggle-display: inline-flex", # the reveal, through task 01's token
|
|
"nav.tabs.is-open", # the links leave the row as the menu
|
|
"position: absolute",
|
|
"top: 100%", # pinned under the whole masthead
|
|
"z-index: 41", # one above the masthead's 40
|
|
"background: var(--bg-raised)",
|
|
"flex-wrap: wrap", # the controls wrap, none is hidden
|
|
# the safe-area inset keeps the first-row #nav-toggle out of the system
|
|
# top-edge zone a real phone's status bar owns (taps there never reach
|
|
# the page; headless emulation has no such zone, so only this pin says
|
|
# the bar still pads for it):
|
|
"padding-top: calc(.5rem + env(safe-area-inset-top, 0px))"):
|
|
assert rule in nav_css, f"the ≤640px nav block lost `{rule}`"
|
|
# Nothing on the phone masthead may be hidden to make the width work: the cluster wraps.
|
|
assert ".masthead-right" in nav_css and "#nav-status" in nav_css, \
|
|
"the mobile masthead hid the status cluster instead of wrapping it"
|
|
|
|
js = _code(client.get("/static/app.js").text)
|
|
binding = js[js.index("const navToggle ="):js.index("function rangeControl(")]
|
|
assert "if (navToggle && appNav)" in binding, \
|
|
"the binding lost the guard that makes it inert on /login"
|
|
assert binding.count("classList.toggle('is-open'") == 1, \
|
|
"a state change now bypasses setNavMenu(), so is-open and aria-expanded can diverge"
|
|
for path in ("navToggle.setAttribute('aria-expanded'",
|
|
"navToggle.addEventListener('click'", # close (and open) on the button
|
|
"event.target.closest('a')", # close on a tab click, delegated
|
|
"event.key === 'Escape'", # close on Esc…
|
|
"navToggle.focus()", # …with focus back on the opener
|
|
"if (!mq.matches) setNavMenu(false)"): # close on resize back to desktop
|
|
assert path in binding, f"the nav binding lost a close path: {path}"
|
|
assert "mq.addEventListener" in binding, "the resize-back close was never registered"
|
|
|
|
js_bp = re.search(r"matchMedia\('\(max-width: (\d+)px\)'\)", js)
|
|
assert js_bp, "app.js no longer reads the nav breakpoint through matchMedia"
|
|
assert js_bp.group(1) == media.group(1), (
|
|
f"the nav breakpoint drifted: CSS hides the tabs below {media.group(1)}px, "
|
|
f"app.js closes the menu below {js_bp.group(1)}px")
|
|
assert media.group(1) == "640", "the nav breakpoint moved off the 640px the phase shipped"
|
|
|
|
|
|
# ---- the three-row masthead (phase 20 task 01) ------------------------------ #
|
|
#
|
|
# `TODO.md:5-6` asked for the per-source indicators and the `⚷ Certs ⚙ Account ☾ Dark` cluster
|
|
# each on their own line (phase 25 task 02 moved both of those lines flush right; the *rows* and
|
|
# the mechanism below are phase 20's). The markup could not change — `#nav-status` is a child
|
|
# of `.masthead-right`, and the tab structure is pinned by
|
|
# `test_the_disks_tab_is_in_the_masthead_of_every_page` — so the rows are made of line breaks:
|
|
# in a `flex-wrap: wrap` container an item whose *base* size fills the line is alone on it (the
|
|
# break happens before shrink), so `flex-basis: 100%` on `.masthead-right` starts row 2 of the
|
|
# bar and the same declaration on the dots starts row 2 *inside* it, pushing the controls to
|
|
# row 3. The alternative the task allowed — `flex-direction: column` — would have put the brand
|
|
# and the tabs on separate lines, which is why the fallback shipped. A browser proves the
|
|
# geometry at 1280 px and 360 px (`data/logs/20-task01-rows.cjs`, `masthead-rows*.png`); these
|
|
# pins keep a later re-flow from putting the buttons back on the dots' line in silence.
|
|
|
|
def _rule_body(css: str, selector: str) -> str:
|
|
"""One rule's declaration block, with the comments stripped.
|
|
|
|
Anchored on the selector itself rather than a section slice: `.masthead-right {` must not
|
|
satisfy a lookup for `.masthead {`, and the ≤640px block restates both selectors.
|
|
"""
|
|
match = re.search(rf"{re.escape(selector)}\s*\{{([^}}]*)\}}", _code(css))
|
|
assert match, f"dash.css no longer has a `{selector} {{ … }}` rule"
|
|
return match.group(1)
|
|
|
|
|
|
def _declarations(css: str, selector: str) -> dict[str, str]:
|
|
"""That block as `{property: value}`, so a pin can ask whether a property exists at all."""
|
|
found: dict[str, str] = {}
|
|
for declaration in _rule_body(css, selector).split(";"):
|
|
prop, sep, value = declaration.partition(":")
|
|
if sep:
|
|
found[prop.strip()] = value.strip()
|
|
return found
|
|
|
|
|
|
def _cascade(css: str, *selectors: str) -> dict[str, str]:
|
|
"""The declarations of several rules merged in cascade order, later winning.
|
|
|
|
Needed for the ≤640px menu: since phase 20 task 03 the dropdown's *geometry* lives on the
|
|
closed `nav.tabs` rule and `.is-open` carries only the visible counterpart, because a box
|
|
that does not exist cannot animate. A pin that asks "does the open menu sit at `top: 100%`
|
|
and paint above the bar?" is asking about the element, so it has to read both rules the way
|
|
the browser does — one duplicated `position` in two rules is the drift this avoids.
|
|
"""
|
|
merged: dict[str, str] = {}
|
|
for selector in selectors:
|
|
merged.update(_declarations(css, selector))
|
|
return merged
|
|
|
|
|
|
def test_the_masthead_is_three_rows_right_justified_at_every_width(client) -> None:
|
|
"""Brand + tabs, then the indicators, then the controls — three rows, two of them flush right.
|
|
|
|
Every assertion here is one declaration the row layout depends on, read out of the
|
|
**served** stylesheet: `flex-wrap` plus the two `flex-basis: 100%` rules are the rows, the
|
|
two `justify-content` values are the right justification (phase 25 task 02, after the
|
|
operator overruled phase 20's `flex-start`), and the absence of `margin-inline-start` on the
|
|
cluster keeps the *mechanism* the alignment rather than the old single-row right-pinning.
|
|
"""
|
|
css = _css(client)
|
|
bar = _declarations(css, ".masthead")
|
|
assert bar.get("flex-wrap") == "wrap" or bar.get("flex-direction") == "column", \
|
|
"the masthead can no longer break into rows: indicators and controls share a line again"
|
|
assert bar.get("flex-direction", "row") == "row", \
|
|
"flex-direction: column puts the brand and the tabs on separate rows"
|
|
assert "height" not in bar, \
|
|
".masthead has a fixed height again — three rows do not fit in --header-h and clip"
|
|
assert bar.get("min-height") == "var(--header-h)", \
|
|
".masthead lost the min-height that keeps a dots-less page from collapsing to one line"
|
|
assert bar.get("gap", "").startswith(".35rem"), \
|
|
"the row gap the three rows need is gone from the masthead's shorthand gap"
|
|
|
|
cluster = _declarations(css, ".masthead-right")
|
|
assert cluster.get("flex-basis") == "100%", \
|
|
"the control cluster no longer starts its own row under the indicators"
|
|
assert cluster.get("justify-content") == "flex-end", \
|
|
"the controls are no longer right-justified in their own row"
|
|
assert "margin-inline-start" not in cluster, \
|
|
"margin-inline-start: auto pushes the cluster back to the right edge of its own row"
|
|
|
|
dots = _declarations(css, "#nav-status")
|
|
assert dots.get("flex-basis") == "100%", \
|
|
"the status indicators no longer get a row to themselves"
|
|
assert dots.get("justify-content") == "flex-end", \
|
|
"the indicators are no longer right-justified"
|
|
|
|
|
|
def test_the_mobile_masthead_block_declares_no_second_bar_height(client) -> None:
|
|
"""One `min-height` in the file, and the safe-area padding still on the phone bar.
|
|
|
|
The ≤640px block used to own `height: auto` + `min-height` because the base rule was a
|
|
fixed `--header-h`; task 01 moved the growth to the base rule, and a mobile restatement is
|
|
exactly the pair that drifts (one says `auto`, the other a number, and the bar clips on a
|
|
phone only). The safe-area line and the wrapping cluster are the phase-18 contract and stay.
|
|
"""
|
|
css = _css(client)
|
|
nav_css = _code(_css_section(css, "mobile nav"))
|
|
block = re.search(r"\.masthead\s*\{([^}]*)\}", nav_css)
|
|
assert block, "the ≤640px block no longer styles the masthead at all"
|
|
for prop in ("height", "min-height", "flex-wrap"):
|
|
assert not re.search(rf"(^|;)\s*{prop}\s*:", block.group(1)), \
|
|
f"the ≤640px masthead re-declares `{prop}` — two rules now decide the bar's height"
|
|
assert "row-gap: .4rem" in block.group(1), "the phone lost its tighter row gap"
|
|
assert "padding-top: calc(.5rem + env(safe-area-inset-top, 0px))" in block.group(1), \
|
|
"the phone bar stopped padding for the system top-edge zone the toggle sits in"
|
|
# The cluster still wraps rather than hiding a control, and so do the dots (phase 29 task 05:
|
|
# five of them are 352px of chips against this bar's 320px content box, and `nowrap` on an item
|
|
# that may not shrink widens the *document* — the sideways scroll phase 18 forbids. `data/logs/
|
|
# 29-task05-tabs.cjs` measures the document at 360px on all eight pages, both widths).
|
|
assert re.search(r"#nav-status\s*\{[^}]*flex-wrap: wrap", nav_css), (
|
|
"the badge row is nowrap again: with five badges it pushes the document past the phone's "
|
|
"viewport instead of taking a second line")
|
|
assert re.search(r"\.masthead-right\s*\{[^}]*flex-wrap: wrap", nav_css)
|
|
|
|
|
|
# ---- the masthead that scrolls away (phase 20 task 02) ---------------------- #
|
|
#
|
|
# `TODO.md:10` — "The navbar is only sticky part way down certain pages on mobile? It shouldn't
|
|
# be sticky at all. We want to maximize the amount of screen space for data viewing." The quirk
|
|
# and the footprint had the same cause, so both went with the stick. What has to stay is the
|
|
# bar's second job: it is the containing block the ≤640px dropdown's `top: 100%` resolves
|
|
# against, which is why the declaration is `relative` and not the default `static`. These pins
|
|
# read the **served** stylesheet, so a re-stick or a `static` regression fails here rather than
|
|
# in a phone's hands. `data/logs/20-task02-unsticky.cjs` is the browser half: scrolled bars at
|
|
# 1280 px and 360 px, and the menu still landing under the bar.
|
|
|
|
def test_the_masthead_scrolls_away_and_stays_the_dropdown_anchor(client) -> None:
|
|
"""Not `sticky`, not `static` either: a relative bar scrolls and still anchors the menu.
|
|
|
|
Every assertion is one half of that trade. `sticky` (with its `top: 0`) is the pinning the
|
|
task removes and a `top` on a relative bar would shift the whole masthead down instead;
|
|
`position: relative` is the positioned ancestor the dropdown needs; the two `z-index`es are
|
|
the stacking sentence "the menu paints one above the bar", which survives the un-stick.
|
|
"""
|
|
css = _css(client)
|
|
bar = _declarations(css, ".masthead")
|
|
assert bar.get("position") == "relative", (
|
|
"the masthead stopped being the positioned ancestor the ≤640px dropdown resolves "
|
|
"against — `sticky` pins the bar to the viewport, `static` moves the menu to the top "
|
|
"of the document")
|
|
assert "sticky" not in bar.values(), \
|
|
".masthead is sticky again: the bar must scroll away for data viewing (TODO.md:10)"
|
|
assert "top" not in bar, \
|
|
"`top` belongs to a sticky bar; on a relative one it offsets the whole masthead"
|
|
assert bar.get("z-index") == "40", \
|
|
".masthead lost its z-index, so the dropdown's 41 no longer means one above the bar"
|
|
|
|
# The mobile half of the contract: the menu still opens directly under the bar and still
|
|
# paints above the content it overlaps — with the bar itself now free to scroll. Read as a
|
|
# cascade: task 03 moved the box onto the closed rule so the slide has something to animate,
|
|
# and `.is-open` is the visibility flip on top of it.
|
|
nav_css = _code(_css_section(css, "mobile nav"))
|
|
dropdown = _cascade(nav_css, "nav.tabs", "nav.tabs.is-open")
|
|
assert dropdown.get("position") == "absolute" and dropdown.get("top") == "100%", \
|
|
"the ≤640px menu no longer opens directly under the masthead"
|
|
assert int(dropdown["z-index"]) > int(bar["z-index"]), \
|
|
"the dropdown no longer stacks above the masthead it hangs from"
|
|
|
|
|
|
# ---- the menu that slides (phase 20 task 03) -------------------------------- #
|
|
#
|
|
# `TODO.md:11` — "I want a nice animation for the hamburger menu - a slide out and slide back in
|
|
# would be perfect. See ~/Project/Personal/brain-of-reese for an example." (The project is
|
|
# `brain_of_reese`; the pattern is its `.app-nav` closed/open transition pair plus the phase-46
|
|
# reduced-motion block.) It is pure CSS over the class flip `setNavMenu()` already performs,
|
|
# which is why `app.js` and its four close paths are untouched. What that pattern *does* force
|
|
# is a re-cut of the two rules: a `display: none` box has no intermediate state, so the closed
|
|
# menu is `visibility: hidden` now and carries the dropdown geometry the transition animates,
|
|
# while `.is-open` carries only the visible counterpart. Three questions are pinned here, in the
|
|
# order that makes the pair readable: does the closed state name the invisible, non-interactive
|
|
# start of the slide; does the open state name its visible end with the `visibility` delay
|
|
# dropped; and does `prefers-reduced-motion` name **both** states, because an open rule left out
|
|
# of that block keeps animating. `data/logs/20-task03-slide.cjs` is the browser half: the
|
|
# computed closed state, a mid-transition frame, the four close paths, the no-phantom-scroll
|
|
# check, and the snap under `prefers-reduced-motion: reduce`.
|
|
|
|
#: The one duration the slide uses, quoted into both states so a pin can demand they agree.
|
|
NAV_SLIDE_MS = "180ms ease"
|
|
|
|
|
|
def test_the_closed_phone_menu_is_the_start_of_the_slide(client) -> None:
|
|
"""Invisible, 8 px high, unhittable — and a box that exists, so it can animate.
|
|
|
|
Every declaration is one half of the closed state the reference pattern needs. `display:
|
|
none` is the one thing the block must **not** contain: it removes the box, and a transition
|
|
on a box that is not rendered never runs — which is exactly why the menu used to snap, and
|
|
why the last assertion is a regression guard and not a style preference.
|
|
"""
|
|
nav_css = _code(_css_section(_css(client), "mobile nav"))
|
|
closed = _declarations(nav_css, "nav.tabs")
|
|
assert closed.get("visibility") == "hidden", \
|
|
"the closed menu is not hidden by a property that can animate"
|
|
assert closed.get("opacity") == "0", "the slide lost its fade: the menu opens at full ink"
|
|
assert closed.get("transform") == "translateY(-8px)", \
|
|
"the slide lost its 8 px rise (`TODO.md:11`): the menu appears in place"
|
|
assert closed.get("pointer-events") == "none", \
|
|
"an invisible menu that takes clicks steals taps from the content under it"
|
|
assert closed.get("transition") == (
|
|
f"opacity {NAV_SLIDE_MS}, transform {NAV_SLIDE_MS}, visibility 0s linear 180ms"), (
|
|
"the closed state's transition is what runs on the way OUT; `visibility` must be "
|
|
f"delayed by exactly the {NAV_SLIDE_MS} slide or the box vanishes before it fades")
|
|
assert "display: none" not in nav_css, (
|
|
"a `display: none` in the ≤640px nav block is a box that cannot animate — the closed "
|
|
"menu became `visibility: hidden` in phase 20 task 03 for exactly this reason")
|
|
|
|
|
|
def test_the_open_phone_menu_is_the_visible_end_of_the_slide(client) -> None:
|
|
"""`transform: none`, and no `visibility` delay — or the menu is invisible for its own 180 ms.
|
|
|
|
`.is-open` out-ranks `nav.tabs` (two classes to one), so these four declarations are what a
|
|
click on `#nav-toggle` actually applies, and the transition line is restated rather than
|
|
inherited: the open state needs the same two animated properties with the delay gone.
|
|
"""
|
|
nav_css = _code(_css_section(_css(client), "mobile nav"))
|
|
open_state = _declarations(nav_css, "nav.tabs.is-open")
|
|
assert open_state.get("visibility") == "visible", "`.is-open` no longer reveals the menu"
|
|
assert open_state.get("opacity") == "1", "the open menu is still half-faded"
|
|
assert open_state.get("transform") == "none", \
|
|
"the open menu never came down the 8 px, so the slide has no end"
|
|
assert open_state.get("pointer-events") == "auto", \
|
|
"the open menu's five links would not take a tap"
|
|
assert open_state.get("transition") == (
|
|
f"opacity {NAV_SLIDE_MS}, transform {NAV_SLIDE_MS}, visibility 0s"), (
|
|
"the open state must animate the same two properties with the `visibility` delay "
|
|
"dropped — the same duration, in the other direction")
|
|
|
|
|
|
def test_the_menu_snaps_in_both_directions_under_reduced_motion(client) -> None:
|
|
"""One reduced-motion block, and it names **both** menu states.
|
|
|
|
The reference project's phase-46 comment is the reason: `.nav.tabs.is-open` out-specifies
|
|
`.nav.tabs`, so stilling only the closed state leaves the open transition running and the
|
|
menu still animates for someone who asked not to. The block is the file's only one, merged
|
|
with the pre-existing universal rule rather than a second block that would have to be kept
|
|
in step with it.
|
|
"""
|
|
code = _code(_css(client))
|
|
blocks = re.findall(r"@media\s*\(prefers-reduced-motion:\s*reduce\)", code)
|
|
assert len(blocks) == 1, (
|
|
f"dash.css carries {len(blocks)} reduced-motion blocks; the menu's rules belong in the "
|
|
"one universal rule, or the two drift")
|
|
block = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\n\}", code, re.S)
|
|
assert block, "the reduced-motion block no longer has a body to read"
|
|
selector = re.search(r"(nav\.tabs[^{]*)\{", block.group(1))
|
|
assert selector, "the reduced-motion block stopped naming the menu at all"
|
|
named = {s.strip() for s in selector.group(1).split(",")}
|
|
assert named == {"nav.tabs", "nav.tabs.is-open"}, (
|
|
"the reduced-motion block must name both menu states: naming only the closed one "
|
|
f"leaves the open transition animating — got {sorted(named)}")
|
|
assert _declarations(block.group(1), "nav.tabs.is-open").get("transition") == "none", \
|
|
"the named open rule stopped stilling the transition"
|
|
|
|
|
|
# ---- the masthead with no polling control (phase 21 task 01) -------------- #
|
|
#
|
|
# `TODO.md:7` — "Completely remove the pause button". The control was four things: a masthead
|
|
# button on the four dashboards, a `Space` binding, the `setPaused`/`_paused` plumbing inside
|
|
# `Dash.poll()`, and a stylesheet comment naming it as a row-3 tenant. A browser is the only
|
|
# thing that can prove the operator no longer sees a button, but the served bytes are what ship
|
|
# it, so those are what fail a test when someone puts it back.
|
|
#
|
|
# The **hidden-tab** skip is deliberately *not* part of the removal and is pinned on the same
|
|
# page for that reason: a backgrounded tab asking for nothing is idle hygiene rather than a
|
|
# decision the operator made, and a poll loop "simplified" back to an unconditional `setTimeout`
|
|
# would be invisible until a laptop battery complained.
|
|
|
|
#: Every spelling the deleted control ever had, in markup, script or stylesheet.
|
|
PAUSE_ARTIFACTS = ('id="pause"', 'name="pause"', "⏸", "▶ Paused",
|
|
"bindPause", "setPaused", "_paused")
|
|
|
|
|
|
def test_no_page_serves_a_button_that_stops_the_polls(client, tmp_path: Path) -> None:
|
|
"""Nine pages, twelve assets, zero pause control.
|
|
|
|
`/login` needs its own anonymous `TestClient` for the reason every other login-page audit
|
|
gives: this `client` is signed in, and a signed-in `GET /login` is a `303` to the dashboard,
|
|
which would audit `/`'s bytes and call them `/login`. The other eight are read as themselves.
|
|
"""
|
|
bodies = {path: client.get(path).text for path in MARKUP_PAGES}
|
|
|
|
db = str(tmp_path / "no-pause-login.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
try:
|
|
with TestClient(app) as anon:
|
|
response = anon.get("/login")
|
|
assert response.status_code == 200, \
|
|
"a signed-out /login must be the form, not a bounce"
|
|
bodies["/login"] = response.text
|
|
finally:
|
|
store.close()
|
|
|
|
assert len(bodies) == 9, "the audit lost a page; every served page owes this check"
|
|
for path, html in bodies.items():
|
|
for artifact in PAUSE_ARTIFACTS:
|
|
assert artifact not in html, f"{path} serves the removed control again: {artifact!r}"
|
|
|
|
for asset in ASSETS:
|
|
body = client.get(asset).text
|
|
for artifact in PAUSE_ARTIFACTS:
|
|
assert artifact not in body, \
|
|
f"{asset} still carries the removed control: {artifact!r}"
|
|
|
|
|
|
def test_the_poll_loop_still_idles_on_a_hidden_tab_and_binds_no_key_for_the_old_control(
|
|
client,
|
|
) -> None:
|
|
"""What survives the deletion, pinned beside what must not come back.
|
|
|
|
The first block is the hidden-tab behaviour the phase keeps (`document.hidden` skips the run
|
|
and reschedules; `visibilitychange` fires one poll on return). The second is the `Space`
|
|
binding: no `event.code` check may live in the shared script, because a key that stops the
|
|
numbers with no control on screen to say so is the failure mode this task exists to retire.
|
|
The third is the half of the old listener that was never about pausing — `T` still toggles
|
|
the theme, on the same four pages it always did.
|
|
"""
|
|
app_js = client.get("/static/app.js").text
|
|
loop = app_js[app_js.index("function poll(fn, ms) {"):app_js.index(" // ---- chrome")]
|
|
assert "if (stopped || inFlight || document.hidden) { schedule(); return; }" in loop, \
|
|
"a hidden tab no longer skips its poll — the idle-hygiene guard is not the button"
|
|
assert "document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); })" in loop, \
|
|
"returning to a backgrounded tab no longer fires a poll — the page would sit on old numbers"
|
|
|
|
assert "event.code" not in app_js, \
|
|
"a key-code binding is back in the shared script — the only one it carried was the deleted control's"
|
|
assert "'Space'" not in app_js, "the deleted control's keyboard shortcut came back"
|
|
|
|
assert "event.key === 't' || event.key === 'T'" in app_js, \
|
|
"`T` stopped toggling the theme: the shortcut that shared the deleted binding survives"
|
|
for name in ("page-system.js", "page-disks.js", "page-llm.js", "page-ups.js"):
|
|
assert "Dash.bindShortcuts();" in client.get(f"/static/{name}").text, \
|
|
f"{name} no longer opts in to the theme shortcut it always had"
|
|
|
|
|
|
def test_the_header_status_dot_picks_up_the_disks_job(client) -> None:
|
|
"""`disks` gets its own dot instead of hiding inside `hosts`.
|
|
|
|
The same agent can answer the 10-second utilisation poll and have a wedged SMART pass;
|
|
one dot for both would have to be right about neither.
|
|
"""
|
|
app_js = client.get("/static/app.js").text
|
|
assert "{ key: 'disks', label: 'disks', href: '/disks' }" in app_js
|
|
assert "s.dashboard === job.key" in app_js, "the dot stopped filtering by dashboard"
|
|
|
|
|
|
def test_disks_page_draws_one_card_per_physical_drive_from_the_api(client) -> None:
|
|
"""The card is a drive, keyed by the agent's device name, patched in place.
|
|
|
|
A mount or a partition would need a filesystem field, and `/system` already answers "is
|
|
the filesystem full"; if one leaks back in here, the page stops being about the drive.
|
|
"""
|
|
html = client.get("/disks").text
|
|
js = _disks_js(client)
|
|
assert 'id="hosts"' in html and 'id="hosts-empty"' in html
|
|
assert "fetchJSON('/api/disks/snapshot')" in js
|
|
assert "row.path" in js and "/dev/" in js, "the card stopped naming the device it is about"
|
|
# identity + verdict lead the card: no hero row, no big single number
|
|
assert 'class="heroes"' not in html, "the disk card grew a hero row"
|
|
assert "Dash.hero(" not in js
|
|
for filesystem in ("mountpoint", "fstype", "free_bytes", "worst_mount"):
|
|
assert filesystem not in js, f"{filesystem} leaked into the drive page"
|
|
# built once per device and patched, so a 16-drive host does not rebuild 16 panels a poll
|
|
assert "seen.get(row.device)" in js and "renderDiskCard(panel, row)" in js
|
|
assert "pruneHosts(" in js, "a drive that left the host keeps its card forever"
|
|
|
|
|
|
def test_disk_cards_print_the_drive_numbers_and_colour_only_rated_ones(client) -> None:
|
|
"""Temp, power-on hours and wear, coloured by the API's verdicts and nothing else.
|
|
|
|
The odometer is the deliberate exception: `severity.THRESHOLDS` has no `disk.power_on_hours`
|
|
row, so the cell is written with a `null` verdict and cannot go green at 100 h or red at
|
|
60 000 h. A drive that publishes no wear (the recorded HDD shape) prints `n/a` because
|
|
`setTxt` is the only writer.
|
|
"""
|
|
js = _disks_js(client)
|
|
for field in ("smart.temp_c", "smart.power_on_hours", "smart.percentage_used",
|
|
"row.health"):
|
|
assert field in js, f"{field} is no longer on the card"
|
|
for verdict in ("severity.temp_c", "severity.percentage_used", "severity.health"):
|
|
assert verdict in js, f"the card stopped rendering {verdict}"
|
|
assert "severityFor" not in js, "the drive page wrote a threshold of its own"
|
|
body = js[js.index("function renderDiskCard("):js.index("/** Text that prints nothing")]
|
|
assert "severity.temp_c" in body and "severity.percentage_used" in body
|
|
assert re.search(r"setMetric\(card, 'hours',[^;]*null\)", body), (
|
|
"the odometer is being graded by something severity.py does not have")
|
|
# the drive's own word, and `unknown` for a drive that gave none — never a green pass
|
|
assert "'FAILED'" in body and "'unknown'" in body
|
|
|
|
|
|
def test_disk_alarm_chips_exist_only_while_a_counter_is_non_zero(client) -> None:
|
|
"""Zero and unknown both draw no chip; only a non-zero counter draws a red one.
|
|
|
|
The `/llm` preemption rule: a permanent "0 pending sectors" row trains the eye to ignore
|
|
it, and grading a counter green would call a quiet drive an achievement. `severity.py`
|
|
rates none of these, so the chip's presence is the entire signal here.
|
|
"""
|
|
js = _disks_js(client)
|
|
for counter in ("reallocated_sector_ct", "current_pending_sector_ct",
|
|
"offline_uncorrectable"):
|
|
assert counter in js, f"{counter} is no longer an alarm"
|
|
assert "value <= 0) continue" in js, "a zero now draws a chip"
|
|
assert "alarms.hidden = chips.length === 0" in js, "the empty chip row stays on screen"
|
|
assert 'class="chip fail"' in js
|
|
css = client.get("/static/dash.css").text
|
|
assert ".alarms {" in css and ".chip.fail" in css
|
|
|
|
|
|
def test_a_host_without_smartctl_is_dimmed_with_a_one_line_why_and_never_red(client) -> None:
|
|
"""Unconfigured is not broken (invariant 3), and the three answers stay three.
|
|
|
|
`smartctl_available` is `false` (install smartmontools — the agent said so, so the page
|
|
may say it), `null` (nobody told us either way, so name the fix without claiming to know
|
|
which half is missing), or the agent is not answering at all, which is a different
|
|
sentence again. None of the three reddens anything.
|
|
"""
|
|
js = _disks_js(client)
|
|
html = client.get("/disks").text
|
|
assert "smartctl not available on" in js
|
|
assert "disk group" in js, "the why stopped naming the grant that fixes it"
|
|
assert "smartctl_available === false" in js and "smartctl_available === true" in js
|
|
assert "host.unreachable" in js, "a dead agent must not be reported as a missing binary"
|
|
assert "smart_errors" in js, "the agent's own sentence is no longer printed"
|
|
assert "classList.toggle('stale'" in js
|
|
assert 'class="banner info why" hidden' in js, "the why-line has no home on the card"
|
|
assert "banner fail" not in js and html.count("banner") == 0, (
|
|
"a missing binary reddens the page")
|
|
|
|
|
|
def test_disks_charts_are_collapsed_lazy_and_cost_one_history_request_per_host(client) -> None:
|
|
"""`/system`'s chart discipline, reused: collapsed, painted only while open, one XHR.
|
|
|
|
The request still names every series the host could draw (temperature and wear per
|
|
drive), because `/api/disks/history` answers an unknown name as an empty series and a
|
|
panel should never have to discover series it did not ask for.
|
|
"""
|
|
js = _disks_js(client)
|
|
assert '<details class="charts-box"' in js
|
|
guard = js[js.index("function drawHostCharts("):js.index("async function renderHostCharts(")]
|
|
assert "if (!chartsOpen(card)) return;" in guard, (
|
|
"a collapsed stack is being built and drawn every poll")
|
|
history = js[js.index("async function renderHostCharts("):js.index("/**\n * Drop cards")]
|
|
assert history.count("fetchJSON(") == 1, "more than one history request for one host"
|
|
assert "/api/disks/history" in history
|
|
assert "streams.temp.concat(streams.wear)" in history, (
|
|
"the request no longer names every series the panel could draw")
|
|
prune = js[js.index("function pruneHosts("):js.index("// ---- fleet header")]
|
|
for entry in ("state.hosts.delete", "state.disks.delete", "state.charts.delete",
|
|
"state.history.delete", "state.payloads.delete"):
|
|
assert entry in prune, f"a pruned host keeps its {entry}"
|
|
|
|
|
|
def test_every_disks_chart_names_itself_for_assistive_tech(client) -> None:
|
|
"""An unlabelled plot is silence, and the legend is a sibling that labels nothing."""
|
|
js = _code(_disks_js(client))
|
|
labels = re.findall(r"ariaLabel: `([^`]+)`", js)
|
|
assert len(labels) == 2, "every drive chart must name itself (temperature, wear)"
|
|
assert all(label.startswith("${host.name}") for label in labels), (
|
|
"a chart that does not say which host it plots belongs on no page")
|
|
|
|
|
|
def test_the_disks_fleet_header_reads_the_api_sums_and_invents_none(client) -> None:
|
|
"""The header's arithmetic is the API's (`fleet_disk_summary`), never the page's.
|
|
|
|
Hidden below two hosts, because with one box in `HOST_TARGETS` these sums only duplicate
|
|
that host's own cards — `/llm`'s fleet-block rule. And no cell is coloured: a count of
|
|
drives is not a graded number (`severity.py` has no row for one).
|
|
"""
|
|
html = client.get("/disks").text
|
|
js = _disks_js(client)
|
|
for element in ("fleet", "fleet-dot", "fleet-count", "fleet-disks", "fleet-smart",
|
|
"fleet-failed", "fleet-alarms"):
|
|
assert f'id="{element}"' in html, f"the header lost #{element}"
|
|
fleet = js[js.index("function renderFleet("):js.index("// ---- snapshot")]
|
|
for path in ("fleet.hosts_live", "fleet.disks", "fleet.with_smart",
|
|
"fleet.health_failed", "fleet.alarm_disks"):
|
|
assert path in fleet, f"the header no longer reads {path}"
|
|
assert "reduce(" not in fleet and "+=" not in fleet, "the page is summing drives itself"
|
|
assert "severity" not in fleet, "a drive count grew a verdict"
|
|
assert "block.hidden = true" in fleet, "one host's sums duplicate its own cards"
|
|
|
|
|
|
def test_the_disk_panel_style_is_existing_vocabulary_with_no_new_palette(client) -> None:
|
|
"""`.disk` is the `.slot` shape, and the only colours it reaches for are the verdicts."""
|
|
css = _css(client)
|
|
block = _css_section(css, "drive panels")
|
|
for selector in (".disks {", ".disk {", ".disk-head", ".disk-dev", ".disk-model",
|
|
".disk .metrics", ".disk .alarms", ".disk .metric.warn .value",
|
|
".disk .metric.fail .value"):
|
|
assert selector in block, f"dash.css lost {selector}"
|
|
for token in ("var(--warn)", "var(--fail)", "var(--border)", "var(--bg-sunken)",
|
|
"var(--text)"):
|
|
assert token in block, f"the drive panel no longer uses {token}"
|
|
assert "--disk" not in css, "a drive-only colour token appeared"
|
|
assert ".stale" in css, "the dim that marks an unreadable host is gone"
|
|
|
|
|
|
def test_the_overview_prints_the_fourth_source_cadence(client) -> None:
|
|
"""`/` names every source's interval, so a drive card four minutes old reads as behaving."""
|
|
js = client.get("/static/page-index.js").text
|
|
assert "config.disks.interval_seconds" in js
|
|
|
|
|
|
@pytest.mark.parametrize("fixture", ["client", "data_client"])
|
|
def test_summary_carries_the_drive_block_for_programmatic_consumers(
|
|
request, fixture: str) -> None:
|
|
"""`disks` is additive: the same configured-ness, the same hosts, no key retired.
|
|
|
|
`client` has no targets, so it proves the block answers as `False`/`None`/`[]` instead of
|
|
disappearing; `data_client` proves it lists the host the fleet has.
|
|
"""
|
|
test_client = request.getfixturevalue(fixture)
|
|
body = test_client.get("/api/summary").json()
|
|
populated = fixture == "data_client"
|
|
assert body["disks"]["enabled"] is populated
|
|
assert (body["disks"]["fleet"] is not None) is populated
|
|
assert body["disks"]["hosts"] == ([] if not populated else [
|
|
{"name": "driveripper", "disks": None, "with_smart": None, "health_failed": None,
|
|
"alarm_disks": None, "smartctl_available": None, "failed_devices": [],
|
|
"unreachable": True}]), "a configured host with no payload is a row of nones"
|
|
assert [row["name"] for row in body["disks"]["hosts"]] == [
|
|
row["name"] for row in body["system"]["hosts"]]
|
|
|
|
|
|
def test_summary_serves_the_same_counts_the_disks_page_prints(disks_client) -> None:
|
|
"""One projection, two consumers: the summary's numbers *are* the snapshot's."""
|
|
body = disks_client.get("/api/summary").json()
|
|
snapshot = disks_client.get("/api/disks/snapshot").json()
|
|
disks = body["disks"]
|
|
assert disks["fleet"] == snapshot["fleet"], "the header would disagree with the page"
|
|
rows = {row["name"]: row for row in disks["hosts"]}
|
|
live = next(host for host in snapshot["hosts"] if host["name"] == "driveripper")
|
|
assert (rows["driveripper"]["disks"], rows["driveripper"]["with_smart"],
|
|
rows["driveripper"]["health_failed"], rows["driveripper"]["alarm_disks"]) == (
|
|
live["summary"]["disks"], live["summary"]["with_smart"],
|
|
live["summary"]["health_failed"], live["summary"]["alarm_disks"])
|
|
assert rows["driveripper"]["smartctl_available"] is True
|
|
assert rows["driveripper"]["unreachable"] is False
|
|
assert rows["deskwork"]["unreachable"] is True
|
|
assert rows["deskwork"]["disks"] is None, "unknown, never 0 (rule 2)"
|
|
assert sorted(state["key"] for state in disks["states"]) == ["disks:deskwork",
|
|
"disks:driveripper"]
|
|
|
|
|
|
def test_summary_names_the_drive_the_health_verdict_called_failed(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, disk_snapshot: Any) -> None:
|
|
"""Which drive the hardware itself called `FAILED` is part of the fleet answer.
|
|
|
|
A notifier reading `/api/summary` should not have to fetch a second endpoint to name the
|
|
device, and the count and the list have to agree or one of them is wrong.
|
|
"""
|
|
db = str(tmp_path / "summary-failed.sqlite3")
|
|
store = SampleStore(db)
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2,
|
|
host_targets=(Target("driveripper", DEAD_URL),)), store)
|
|
with TestClient(app) as test_client:
|
|
sign_in(test_client)
|
|
now = time.time()
|
|
snapshot = disk_snapshot("driveripper", mode="failed")
|
|
snapshot["ts"] = now
|
|
result = parse_disks_snapshot("driveripper", snapshot, DisksCollector(), now=now)
|
|
app.state.ctx.hub.jobs["disks:driveripper"].state.payload = result.payload
|
|
row = test_client.get("/api/summary").json()["disks"]["hosts"][0]
|
|
assert len(row["failed_devices"]) == 1
|
|
assert row["failed_devices"][0] == result.payload["disks"][0]["device"]
|
|
assert row["health_failed"] == result.payload["summary"]["health_failed"] == 1
|
|
store.close()
|
|
|
|
|
|
# ---- every table scrolls sideways on a phone (phase 27 task 01) -------------- #
|
|
#
|
|
# `TODO.md:7` — "None of the tables are horizontally scrollable on mobile." The measurement is
|
|
# AGENTS.md gap 12: at 360 px `/`'s fleet table is 450-510 px inside a 320 px `section.card` and
|
|
# `.card { overflow: hidden }` clips `WORST DISK` / `GPU BUSY` / `TEMP` / `UP` with nothing to
|
|
# reach them. The fix is one rule inside the repo's existing ≤640px block, taken from the shape
|
|
# the all-widths `table.data.scroll` modifier already proves — `display: block` plus a horizontal
|
|
# scrollbar, so the scroll container is the table and never the document (phase 18's standing
|
|
# guarantee). It is a class rule rather than hand-placed `.scroll` classes because most of those tables
|
|
# exist only in JS-built markup and the class is the whole contract.
|
|
#
|
|
# Two failure modes the pins below are aimed at, both named in the task's assumptions: a
|
|
# `max-height` migrating in from `.scroll` (a phone then gets a height-capped table on top of a
|
|
# horizontal one), and the all-widths `.scroll` rule being edited to "share" the new mobile one.
|
|
#
|
|
# The task assumed one rule would reach every table. Measured on the live fleet at 360 px it did
|
|
# not reach the disclosure tables: `/system`'s GPU / filesystem / process tables came back 520 px
|
|
# wide inside a 320 px card with `scrollWidth === clientWidth`, because the card body is
|
|
# `.body.stack` — a one-column grid — and a grid item's automatic minimum size is its content's
|
|
# min-content width, so the `<details>` grew the column instead of the table overflowing its own
|
|
# box. `min-width: 0` on the card body's items is therefore pinned as the second half of one
|
|
# mechanism: without it the scroll rule is inert for exactly the tables the task asked about.
|
|
# `data/logs/27-task01-scroll.cjs` is the browser half; task 02's audit is the phase's proof.
|
|
|
|
def _media_body(css: str, condition: str) -> str:
|
|
"""The declarations of one `@media (…)` block, brace-matched, comments stripped.
|
|
|
|
`_rule_body` stops at the first `}` because no rule nests; a media block does, and the
|
|
≤640px block's own comments contain example rules with braces in them — hence running the
|
|
section through `_code` first. The caller picks the block by condition, so a pin can ask about
|
|
*the mobile block's* `table.data` without mistaking it for the all-widths rule of the same
|
|
name further down the file.
|
|
"""
|
|
body = _code(css)
|
|
match = re.search(rf"@media\s*\({re.escape(condition)}\)\s*\{{", body)
|
|
assert match, f"dash.css no longer has an `@media ({condition})` block"
|
|
depth, index = 1, match.end()
|
|
while depth:
|
|
char = body[index]
|
|
depth += (char == "{") - (char == "}")
|
|
index += 1
|
|
return body[match.end():index - 1]
|
|
|
|
|
|
def _table_tags(text: str) -> list[str]:
|
|
"""Every `<table …>` opening tag in a document or a JS template, comments already gone."""
|
|
return re.findall(r"<table\b([^>]*)>", text)
|
|
|
|
|
|
def test_every_table_data_scrolls_sideways_at_the_phone_breakpoint(client) -> None:
|
|
"""One mobile rule, horizontal-only, no height cap — and the desktop `.scroll` untouched.
|
|
|
|
Each assertion is one half of the mechanism: `display: block` is what lets the element be the
|
|
card's width while the table inside it is wider, `overflow-x: auto` is the scrollbar that
|
|
appears only when it overflows (so a table that fits paints nothing new at 1280 px), and the
|
|
absence of `max-height` / `overflow-y` / the `overflow` shorthand is the assumption that a
|
|
phone gets a sideways scroll and *only* a sideways scroll.
|
|
"""
|
|
css = _css(client)
|
|
# Scoped to the section the nav pins read, so "the mobile rule" means the repo's one mobile
|
|
# boundary block rather than whichever `@media (max-width: 640px)` happens to come first.
|
|
mobile_block = _media_body(_css_section(css, "mobile nav"), "max-width: 640px")
|
|
mobile = _declarations(mobile_block, "table.data")
|
|
assert mobile.get("display") == "block", (
|
|
"the mobile `table.data` is not a scroll container — its tail columns are clipped by "
|
|
"`.card { overflow: hidden }` again (AGENTS.md gap 12)")
|
|
assert mobile.get("overflow-x") == "auto", (
|
|
"the mobile rule no longer gives a wide table a way to reach its last column")
|
|
for forbidden in ("max-height", "overflow-y", "overflow"):
|
|
assert forbidden not in mobile, (
|
|
f"the mobile rule declares `{forbidden}`: the 320 px height cap is `.scroll`'s "
|
|
"vertical-use contract, not what a phone needs on top of a horizontal scroll")
|
|
|
|
# The second half of the mechanism, in the same block: a grid item may not be narrower than
|
|
# its content unless something says so, and the card bodies are one-column grids — so without
|
|
# this clause the rule above cannot overflow anything inside a disclosure and the tail columns
|
|
# stay clipped by the card. Pinned together because one without the other is the bug.
|
|
stack = _declarations(mobile_block, ".body.stack > *")
|
|
assert stack.get("min-width") == "0", (
|
|
"the mobile block lost the `min-width: 0` that lets a card body be narrower than the "
|
|
"table inside it — the disclosure tables grow the grid column and clip again")
|
|
|
|
# The all-widths rules are the *other* half of the contract: `table.data` still lays out as a
|
|
# table above the breakpoint (no scrollbar where the table fits, no layout shift at 1280 px),
|
|
# and `.scroll` still caps and scrolls both axes wherever it is applied. Read out of the
|
|
# `tables` section because the mobile rule above has the same selector and comes first.
|
|
tables = _css_section(css, "tables")
|
|
base = _declarations(tables, "table.data")
|
|
assert base.get("display", "table") == "table", (
|
|
"`table.data` is a block at every width now — the mobile rule leaked out of its media "
|
|
"block and every table grew a scroll container it does not need")
|
|
assert base.get("width") == "100%", "the base table lost the width that makes it fill its card"
|
|
scroll = _declarations(tables, "table.data.scroll")
|
|
assert scroll == {"display": "block", "overflow": "auto", "max-height": "320px"}, (
|
|
f"`table.data.scroll` changed shape: {scroll} — `#agents-table`'s both-axis box and "
|
|
"320 px cap are phase 21's contract and this phase may not edit them")
|
|
|
|
|
|
def test_a_card_header_wraps_its_facts_instead_of_losing_its_tail(client) -> None:
|
|
"""`.card > header` wraps, and the two free-text pieces are allowed to become narrower.
|
|
|
|
The 6 FAILs of the first phase-27 audit run were not tables: at 360 px the header was a
|
|
single `nowrap` flex line, so its tail — the `.updated` age stamp on `/system` (right
|
|
402-415 px), `/llm` (374-393) and `/ups` (392-531), plus the `apc` card's `.chip.model`
|
|
(376) and `.chip.health` (463) — stood past the card and `.card { overflow: hidden }` ate it.
|
|
"How old is this" and "which box is this" are facts, so the row wraps instead (measured:
|
|
`data/logs/27-task03-verify.cjs`, and `27-task03-control.cjs` shows these same offenders again
|
|
with the declarations neutralised).
|
|
|
|
`min-width: 0` is pinned with `flex-wrap` because one without the other is not the fix: a
|
|
flex item's automatic minimum size is its content's min-content width, so a long OS or model
|
|
string still cannot wrap inside its own box (the `apc` chip measured 290 px against a 252 px
|
|
card). And `white-space: normal` is scoped to a header chip, not to `.chip`: a chip is a
|
|
label elsewhere in this app and keeps its one-line shape.
|
|
"""
|
|
header = _declarations(_css(client), ".card > header")
|
|
assert header.get("flex-wrap") == "wrap", (
|
|
"the card header is a single nowrap flex line again — its tail facts are painted past "
|
|
"the card and clipped (AGENTS.md gap 12's header finding)")
|
|
shrink = _declarations(_css(client), ".card > header .chip")
|
|
assert shrink.get("min-width") == "0" and shrink.get("max-width") == "100%", (
|
|
"a header chip may not become narrower than its own text: with `flex-wrap` alone the "
|
|
"longest chip still cannot wrap and the row's tail leaves the card")
|
|
assert shrink.get("white-space") == "normal", (
|
|
"a header chip that has to share a line now truncates or overflows instead of wrapping; "
|
|
"the label has to stay readable in full")
|
|
assert _declarations(_css(client), ".card > header h3").get("min-width") == "0", (
|
|
"the header title lost the same allowance — a long host name cannot wrap either")
|
|
# Read out of the chips section, because `_rule_body` anchors on the selector text and the
|
|
# header exception above *contains* `.chip {` — the base vocabulary lives further down.
|
|
assert _declarations(_css_section(_css(client), "bars, dots, chips"), ".chip").get(
|
|
"white-space") == "nowrap", (
|
|
"`.chip` itself stopped being one line: this fix is an exception for header chips, not a "
|
|
"change to the chip vocabulary every other page draws with")
|
|
|
|
|
|
def test_a_card_disclosure_scrolls_what_it_reveals_at_every_width(client) -> None:
|
|
"""The control-width half of gap 12: the *disclosure* scrolls, so the card stops clipping.
|
|
|
|
Task 01 logged this as a FINDING at 1280 px and left the decision here. Measured, `/system`
|
|
three cards across: the "More detail" item grew to its table's min-content (520 px inside a
|
|
404 px card), and because the card clips, the GPU table's last column — "97.8 GiB", tail
|
|
right 554 against a card content edge of 424 — was painted *under the next card* (which
|
|
starts at 438 px), unreachable by any gesture. The `charts-box` item beside it was stretched
|
|
to the same 520 px, so a chart's newest edge went with it.
|
|
|
|
The fix is one declaration on the disclosure, and these assertions are why it is that one:
|
|
horizontal only (no `max-height`, no `overflow-y`, no `overflow` shorthand — a height cap is
|
|
`.scroll`'s contract, and a vertical scrollport inside a card body would be a second one),
|
|
and **outside** the ≤640px block, because the defect it fixes is at the control width. It is
|
|
not the mobile rule hoisted: `table.data` above stays `display: table`, which is what keeps
|
|
every table that fits stretched across its card instead of shrink-wrapped — pinned next door
|
|
and re-measured by `data/logs/27-task03-verify.cjs`.
|
|
"""
|
|
css = _css(client)
|
|
disc = _declarations(_css_section(css, "tables"), ".card > .body.stack > details")
|
|
assert disc.get("overflow-x") == "auto", (
|
|
"a disclosure no longer scrolls what it reveals: a table wider than its card grows the "
|
|
"item past the card again and `.card { overflow: hidden }` clips the tail column")
|
|
for forbidden in ("max-height", "overflow-y", "overflow"):
|
|
assert forbidden not in disc, (
|
|
f"the disclosure rule declares `{forbidden}`: this fix is a sideways scroll only — "
|
|
"the 320 px cap belongs to `table.data.scroll` and a vertical scrollport here would "
|
|
"hide rows the operator has not chosen to hide")
|
|
mobile = _media_body(_css_section(css, "mobile nav"), "max-width: 640px")
|
|
assert ".card > .body.stack > details" not in mobile, (
|
|
"the disclosure scroll moved inside the ≤640px block — the finding it repairs was measured "
|
|
"at 1280 px, where it would then do nothing")
|
|
|
|
|
|
def test_the_radiance_reqs_table_scrolls_instead_of_stretching_its_card(client) -> None:
|
|
"""The radiance strip's request table must not grow the card to its min-content.
|
|
|
|
Measured on the live fleet (1280 px, two strip cards across, each 613 px): with a
|
|
request in flight the `.reqs` table — the union of the row's ten published keys —
|
|
sat at ~950 px of min-content, and because the strip body is a one-column grid
|
|
(`.strip > .card > .body`) with an implicit auto column, a grid item's automatic
|
|
minimum size is its content's min-content width, so the *column* grew to ~946 px.
|
|
Every row of the card laid out at 946 px inside a 613 px card: the value column of
|
|
each `dl.kv` row resolved to ~857 px, the right-aligned value span painted at
|
|
x≈1582 against a card edge at 1260, and `.card { overflow: hidden }` ate it — the
|
|
strip printed its labels with no values while the request row and the spec curve
|
|
(their own boxes) painted fine. `min-width: 0` is the first half: without it the
|
|
table's min-content still sets the column and nothing below can overflow. `overflow-x:
|
|
auto` is the second: the table reaches its last column by scrolling inside the card,
|
|
only where it is wider than the card (a table that fits paints no scrollbar —
|
|
horizontal only, no height cap, no vertical scrollport).
|
|
"""
|
|
reqs = _declarations(_css_section(_css(client), "engine slot grid"), ".reqs")
|
|
assert reqs.get("min-width") == "0", (
|
|
"the `.reqs` item may not be narrower than its table: without this the ten-column "
|
|
"request table's min-content grows the strip body's grid column and every value "
|
|
"cell of the card paints past the card, clipped by `.card { overflow: hidden }`")
|
|
assert reqs.get("overflow-x") == "auto", (
|
|
"the request table lost its sideways scroll inside the card — the columns beyond "
|
|
"the card edge are unreachable again")
|
|
for forbidden in ("max-height", "overflow-y", "overflow"):
|
|
assert forbidden not in reqs, (
|
|
f"the `.reqs` rule declares `{forbidden}`: this is a sideways scroll only — a "
|
|
"height cap or vertical scrollport here would hide rows the operator has not "
|
|
"chosen to hide")
|
|
# the strip body is the grid the item sits in: a one-column body is what the
|
|
# min-content growth acts on, so the fix is inert if the body stops being a grid
|
|
body = _declarations(_css_section(_css(client), "engine stats strip"), ".strip > .card > .body")
|
|
assert body.get("display") == "grid", (
|
|
"the strip body is not a one-column grid anymore — re-measure before touching `.reqs`")
|
|
|
|
|
|
def test_every_table_on_every_page_carries_the_class_the_rule_targets(client) -> None:
|
|
"""The mobile rule is class-level, so a `<table>` without `data` would silently not scroll.
|
|
|
|
Both halves are read from the served bytes: the static tables in the page templates and
|
|
the five built at runtime by `page-system.js` / `page-ups.js` (four inside `/system`'s "More
|
|
detail", one the NUT variables list). This is the drift the task chose the class rule over
|
|
the hand-placed `.scroll` classes it would replace — and it cuts both ways, which is why it is a test.
|
|
"""
|
|
tags = [tag for page in MARKUP_PAGES for tag in _table_tags(_code(client.get(page).text))]
|
|
tags += [tag for script in PAGE_SCRIPTS.values()
|
|
for tag in _table_tags(_code(client.get(f"/static/{script}").text))]
|
|
assert len(tags) >= 7, f"only {len(tags)} tables found — the sweep stopped reading the pages"
|
|
for tag in tags:
|
|
assert re.search(r'class="[^"]*\bdata\b', tag), (
|
|
f"a table outside the rule's class selector: <table{tag}> — it will clip its tail "
|
|
"columns at 360 px instead of scrolling them")
|
|
|
|
|
|
# ---- events --------------------------------------------------------------- #
|
|
|
|
def test_events_are_returned_in_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
db = str(tmp_path / "events.sqlite3")
|
|
store = SampleStore(db)
|
|
store.log_event(Event(ts=time.time(), source="host", host="deskwork", kind="down",
|
|
severity="error", message="deskwork is not answering"))
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
with TestClient(app) as client:
|
|
sign_in(client)
|
|
events = client.get("/api/events").json()["events"]
|
|
assert events[0]["host"] == "deskwork"
|
|
assert events[0]["severity"] == "error"
|
|
store.close()
|
|
|
|
|
|
def test_event_rows_say_their_subject_once(client) -> None:
|
|
"""`poller.py` messages name their own subject, and the row prints a host chip too.
|
|
|
|
"gufo gufo is answering again after 1 failure" reads as two problems — the same defect
|
|
`alerts._clean_error()` already fixes for the overview's alert list. `/ups` had the
|
|
mirror-image bug: it `replace()`d `host + ':'` *anywhere*, so "Quick battery test sent
|
|
to apc: OK" printed as "Quick battery test sent to OK". All three feeds now share
|
|
`Dash.eventText()`, which strips the wrapper and a **leading** name only.
|
|
"""
|
|
app_js = client.get("/static/app.js").text
|
|
helper = app_js[app_js.index("function eventText("):app_js.index("// ---- DOM helpers")]
|
|
assert "eventText" in app_js[app_js.index("return {"):], "eventText is not exported"
|
|
assert "^UpstreamError:\\s+" in helper, "the wrapper `alerts.py` drops is back"
|
|
assert "new RegExp" in helper and "stripped.trim() ? stripped : out" in helper, \
|
|
"the host strip is no longer anchored to the front, or can empty a message"
|
|
for page in ("/static/page-system.js", "/static/page-llm.js", "/static/page-ups.js"):
|
|
js = client.get(page).text
|
|
assert "eventText(event)" in js, f"{page} prints the raw message again"
|
|
assert "replace(event.host" not in client.get("/static/page-ups.js").text, \
|
|
"/ups is mangling the host name mid-message again"
|
|
|
|
|
|
# ---- the upstream write endpoints (two, both gated) ------------------------ #
|
|
|
|
def test_command_endpoint_is_post_only(ups_client) -> None:
|
|
assert ups_client.post("/api/ups/command", json={}).status_code in (400, 403, 404)
|
|
assert ups_client.put("/api/ups/command", json={}).status_code == 405
|
|
assert ups_client.delete("/api/ups/command").status_code == 405
|
|
|
|
|
|
def test_commands_are_offered_when_actions_are_enabled(ups_client) -> None:
|
|
body = wait_for_payload(ups_client, "/api/ups/snapshot")
|
|
assert body["simulated"] is True, "the simulator must label itself"
|
|
assert body["actions_enabled"] is True
|
|
ups = body["ups"][0]
|
|
enabled = [item for item in ups["commands"] if item["enabled"]]
|
|
assert enabled, "with NUT_ALLOW_ACTIONS=1 and a simulator, safe commands are sendable"
|
|
assert all(item["risk"] in ("safe", "caution", "dangerous") for item in ups["commands"])
|
|
|
|
|
|
def test_a_safe_command_actually_reaches_the_driver(ups_client) -> None:
|
|
body = wait_for_payload(ups_client, "/api/ups/snapshot")
|
|
name = body["ups"][0]["name"]
|
|
response = ups_client.post("/api/ups/command",
|
|
json={"ups": name, "command": "test.panel.start"})
|
|
assert response.status_code == 200, response.text
|
|
payload = response.json()
|
|
assert payload["ok"] is True and payload["simulated"] is True
|
|
events = ups_client.get("/api/events").json()["events"]
|
|
assert any("test.panel.start" in event["message"] or "Panel test" in event["message"]
|
|
for event in events), "every sent command must leave an audit trail"
|
|
|
|
|
|
def test_a_dangerous_command_needs_the_confirmation_word(ups_client) -> None:
|
|
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
|
|
deep = "test.battery.start.deep"
|
|
refusal = ups_client.post("/api/ups/command", json={"ups": name, "command": deep})
|
|
assert refusal.status_code == 422
|
|
assert "DANGER" in refusal.json()["detail"]
|
|
assert ups_client.post("/api/ups/command",
|
|
json={"ups": name, "command": deep, "confirm": "DANGER"}).status_code == 200
|
|
|
|
|
|
def test_a_command_the_driver_does_not_advertise_is_refused_before_it_is_sent(
|
|
ups_client) -> None:
|
|
"""Being in the risk catalog is not permission; `LIST CMD` from the driver is (invariant 1).
|
|
|
|
`shutdown.return` is a real NUT command the catalog knows and this UPS does not offer,
|
|
so the refusal must come from the advertisement check and not from the confirmation gate.
|
|
"""
|
|
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
|
|
response = ups_client.post("/api/ups/command",
|
|
json={"ups": name, "command": "shutdown.return"})
|
|
assert response.status_code == 400
|
|
assert "does not advertise" in response.json()["detail"]
|
|
|
|
|
|
def test_cutting_the_output_is_refused_while_the_ups_is_already_on_battery(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Invariant 1, the one that matters most: no power cut while the pack is carrying the load.
|
|
|
|
Driven through the simulator's own state machine — a quick battery test puts the pack on
|
|
battery — so the 409 comes from the same poll the buttons are drawn from, with a 0.5s
|
|
interval to keep the wait short. Nothing here can touch real hardware: `NUT_SIMULATE=1`.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
|
|
nut_allow_actions=True, nut_interval=0.5) as test_client:
|
|
name = wait_for_payload(test_client, "/api/ups/snapshot")["ups"][0]["name"]
|
|
start = test_client.post("/api/ups/command",
|
|
json={"ups": name, "command": "test.battery.start.quick"})
|
|
assert start.status_code == 200
|
|
deadline = time.time() + 6.0
|
|
card: dict[str, Any] = {}
|
|
while time.time() < deadline:
|
|
card = next(item for item in test_client.get("/api/ups/snapshot").json()["ups"]
|
|
if item["name"] == name)
|
|
if card["on_battery"]:
|
|
break
|
|
time.sleep(0.05)
|
|
assert card.get("on_battery") is True, "the quick test must put the simulated pack on battery"
|
|
refusal = test_client.post("/api/ups/command",
|
|
json={"ups": name, "command": "load.off",
|
|
"confirm": "DANGER"})
|
|
assert refusal.status_code == 409
|
|
assert "on battery" in refusal.json()["detail"]
|
|
# and the same command is still offered once the pack is back on line power
|
|
assert not any(item["command"] == "load.off" and item["enabled"]
|
|
for item in card["commands"]), "the panel must not offer a blocked button"
|
|
|
|
|
|
class ScriptedNutClient:
|
|
"""`nut.NutClient` with the socket left out: the real code path, no battery attached.
|
|
|
|
`AGENTS.md` keeps live tests read-only, so this is where the UPS write endpoint's real
|
|
branch gets proven. The variable names are the ones `VAR_MAP` already pins from real
|
|
upsd captures; only the replies are scripted (a fake for behaviour, per the test policy).
|
|
"""
|
|
|
|
#: set per test: an exception instance to raise from `instcmd`, e.g. a refusal
|
|
refusal: Exception | None = None
|
|
sent: list[tuple[str, str]]
|
|
logged_in: list[str]
|
|
|
|
def __init__(self, host: str, port: int, *, timeout: float = 4.0,
|
|
username: str | None = None, password: str | None = None) -> None:
|
|
self.credentials = (username, password)
|
|
|
|
async def __aenter__(self) -> ScriptedNutClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_exc: object) -> None:
|
|
return None
|
|
|
|
async def list_ups(self) -> list[dict[str, str]]:
|
|
return [{"name": "apc", "description": "APC Back-UPS RS 1500MS"}]
|
|
|
|
async def list_vars(self, ups: str) -> dict[str, str]:
|
|
return {"ups.mfr": "APC", "ups.model": "Back-UPS RS 1500MS",
|
|
"ups.status": "OL", "battery.charge": "100", "ups.load": "18",
|
|
"battery.voltage": "13.6", "input.voltage": "230",
|
|
"battery.mfr.date": "2022-04", "ups.test.result": "Ok last test passed"}
|
|
|
|
async def list_cmds(self, ups: str) -> list[str]:
|
|
return ["test.panel.start", "beeper.mute", "load.off"]
|
|
|
|
async def list_rw_vars(self, ups: str) -> list[str]:
|
|
return []
|
|
|
|
async def login(self) -> None:
|
|
assert self.credentials[0], "a command send must authenticate as a named user"
|
|
ScriptedNutClient.logged_in.append(str(self.credentials[0]))
|
|
|
|
async def instcmd(self, ups: str, command: str) -> str:
|
|
if ScriptedNutClient.refusal is not None:
|
|
raise ScriptedNutClient.refusal
|
|
ScriptedNutClient.sent.append((ups, command))
|
|
return "OK Start Panel Test"
|
|
|
|
|
|
@pytest.fixture
|
|
def real_upsd_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[type[ScriptedNutClient]]:
|
|
"""Swap the module-level `NutClient` so the collector and the send use the same stand-in."""
|
|
ScriptedNutClient.sent = []
|
|
ScriptedNutClient.logged_in = []
|
|
ScriptedNutClient.refusal = None
|
|
monkeypatch.setattr(nut, "NutClient", ScriptedNutClient)
|
|
yield ScriptedNutClient
|
|
|
|
|
|
@pytest.fixture
|
|
def nut_client_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
real_upsd_client: type[ScriptedNutClient]) -> Iterator[TestClient]:
|
|
"""A non-simulated UPS app: real code paths, scripted upsd, 0.5s polls."""
|
|
with build_client(tmp_path, monkeypatch, nut_host="127.0.0.1", nut_user="dashboards",
|
|
nut_password="not-the-real-one", nut_allow_actions=True,
|
|
nut_interval=0.5) as test_client:
|
|
yield test_client
|
|
|
|
|
|
def test_a_real_upsd_send_authenticates_sends_and_audits(nut_client_app,
|
|
real_upsd_client) -> None:
|
|
body = wait_for_payload(nut_client_app, "/api/ups/snapshot")
|
|
assert body["simulated"] is False, "this test is about the branch that talks to upsd"
|
|
name = body["ups"][0]["name"]
|
|
response = nut_client_app.post("/api/ups/command",
|
|
json={"ups": name, "command": "test.panel.start"})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["simulated"] is False
|
|
assert real_upsd_client.sent == [(name, "test.panel.start")]
|
|
assert real_upsd_client.logged_in, "instcmd requires a LOGIN first (upsd refuses otherwise)"
|
|
events = nut_client_app.get("/api/events").json()["events"]
|
|
assert any(event["kind"] == "action" and name in event["message"] for event in events)
|
|
|
|
|
|
def test_a_refusal_from_upsd_is_a_502_and_an_error_event(nut_client_app,
|
|
real_upsd_client) -> None:
|
|
real_upsd_client.refusal = nut.NutError("Access denied", code="ACCESS-DENIED")
|
|
name = wait_for_payload(nut_client_app, "/api/ups/snapshot")["ups"][0]["name"]
|
|
response = nut_client_app.post("/api/ups/command",
|
|
json={"ups": name, "command": "beeper.mute"})
|
|
assert response.status_code == 502
|
|
detail = response.json()["detail"]
|
|
assert "ACCESS-DENIED" in detail or "Access denied" in detail, detail
|
|
assert real_upsd_client.sent == [], "a refused command was never accepted"
|
|
events = nut_client_app.get("/api/events").json()["events"]
|
|
failures = [event for event in events if event["kind"] == "action"
|
|
and event["severity"] == "error"]
|
|
assert failures, "an upsd refusal has to leave a trace, not just an HTTP status"
|
|
|
|
|
|
def test_unknown_commands_are_refused_even_with_actions_on(ups_client) -> None:
|
|
name = wait_for_payload(ups_client, "/api/ups/snapshot")["ups"][0]["name"]
|
|
for command in ("load.format", "rm -rf /", "INSTCMD x y"):
|
|
response = ups_client.post("/api/ups/command", json={"ups": name, "command": command})
|
|
assert response.status_code == 400, command
|
|
|
|
|
|
def test_commands_for_an_unknown_ups_are_404(ups_client) -> None:
|
|
wait_for_payload(ups_client, "/api/ups/snapshot")
|
|
response = ups_client.post("/api/ups/command",
|
|
json={"ups": "not-real", "command": "beeper.mute"})
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_missing_fields_are_a_400(ups_client) -> None:
|
|
assert ups_client.post("/api/ups/command", json={"ups": ""}).status_code == 400
|
|
|
|
|
|
def test_actions_flag_off_blocks_every_command(tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1") as client:
|
|
body = wait_for_payload(client, "/api/ups/snapshot")
|
|
name = body["ups"][0]["name"]
|
|
assert body["actions_enabled"] is False
|
|
response = client.post("/api/ups/command",
|
|
json={"ups": name, "command": "beeper.mute"})
|
|
assert response.status_code == 403
|
|
assert "NUT_ALLOW_ACTIONS" in response.json()["detail"]
|
|
|
|
|
|
def test_read_only_beats_the_actions_flag(tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
with build_client(tmp_path, monkeypatch, simulate=True, nut_host="127.0.0.1",
|
|
nut_allow_actions=True, read_only=True) as client:
|
|
body = wait_for_payload(client, "/api/ups/snapshot")
|
|
name = body["ups"][0]["name"]
|
|
response = client.post("/api/ups/command",
|
|
json={"ups": name, "command": "beeper.mute"})
|
|
assert response.status_code == 403
|
|
commands = wait_for_payload(client, "/api/ups/commands")
|
|
assert commands["read_only"] is True
|
|
assert all(item["enabled"] is False for ups in commands["ups"]
|
|
for item in ups["commands"])
|
|
|
|
|
|
def test_ups_history_answers_for_simulated_ups(ups_client) -> None:
|
|
wait_for_payload(ups_client, "/api/ups/snapshot")
|
|
time.sleep(1.2)
|
|
body = ups_client.get("/api/ups/history", params={"metrics": "ups.charge_pct",
|
|
"range": "1h"}).json()
|
|
assert body["ups"], "the UPS page needs a name to chart before any history exists"
|
|
|
|
|
|
def response_ok(response) -> bool:
|
|
assert response.status_code == 200, response.text
|
|
return True
|
|
|
|
|
|
def test_samples_written_by_the_poller_are_queryable(tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The write path (poller -> store) and the read path (/api/history) must agree on names."""
|
|
db = str(tmp_path / "roundtrip.sqlite3")
|
|
store = SampleStore(db)
|
|
store.add_many([Sample("llm", "gufo", "llm.decode_tokens_per_second", 40.0, time.time() - 60),
|
|
Sample("llm", "gufo", "llm.decode_tokens_per_second", 45.0, time.time() - 30)])
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
with TestClient(app) as client:
|
|
sign_in(client)
|
|
metrics = client.get("/api/metrics", params={"source": "llm"}).json()["metrics"]
|
|
assert "llm.decode_tokens_per_second" in metrics
|
|
points = client.get("/api/history", params={"source": "llm", "host": "gufo",
|
|
"metric": "llm.decode_tokens_per_second",
|
|
"range": "15m"}).json()["points"]
|
|
assert [p["value"] for p in points] == [40.0, 45.0]
|
|
store.close()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The certificate authority surface (phase 15 task 04)
|
|
#
|
|
# These routes mint, re-sign, and revoke the identities every agent in the fleet is dialled with,
|
|
# so the assertions are about **stored rows and wire effects** rather than about JSON being shaped
|
|
# nicely: a read that issued a certificate, a typo that wrote a row, a revocation that did not
|
|
# reach the collector, and a key that left `meta` are the four ways this surface can be wrong while
|
|
# every status code still reads 200.
|
|
#
|
|
# The fleet here is loopback ports nobody listens on. That is deliberate: nothing on this surface
|
|
# is allowed to need a live agent, so a refused connection is both the whole environment and the
|
|
# control that says "a dial was attempted, and it was not this".
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
#: Three configured hosts, none of them reachable, on the scheme a protected exporter is dialled
|
|
#: by. The URL host is also what a rotated certificate's SAN must carry, so these are the SANs the
|
|
#: assertions below read.
|
|
DEAD_TARGETS = (Target(name="deskwork", url="https://127.0.0.1:1"),
|
|
Target(name="driveripper", url="https://127.0.0.1:2"),
|
|
Target(name="gateway", url="https://127.0.0.1:3"))
|
|
|
|
#: The whole `GET /api/certs` document and one agent row, pinned as a table so a panel that
|
|
#: retires can never shrink the payload (`/api/disks`' idiom) and task 05's page has a contract.
|
|
CERT_DOCUMENT_KEYS = {"root", "client", "agents"}
|
|
CERT_TIMESERIAL_KEYS = {"serial", "not_after"}
|
|
CERT_AGENT_KEYS = {"name", "configured", "serial", "not_after", "status", "bundle_rev",
|
|
"deployed_bundle_rev", "san"}
|
|
|
|
|
|
def cert_document(client: TestClient) -> dict[str, Any]:
|
|
"""`GET /api/certs`, with the status asserted: a refusal here would pass a `.get()` chain."""
|
|
response = client.get("/api/certs")
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert set(body) == CERT_DOCUMENT_KEYS, "the CA document grew or lost a top-level key"
|
|
for block in ("root", "client"):
|
|
assert set(body[block]) == CERT_TIMESERIAL_KEYS, block
|
|
assert int(body[block]["serial"]) > 0, "a serial is the decimal text of a positive int"
|
|
assert body[block]["not_after"] > time.time(), "a stored CA may not already be expired"
|
|
return body
|
|
|
|
|
|
def cert_agent_rows(client: TestClient, name: str | None = None) -> list[dict[str, Any]]:
|
|
"""The agent rows, every one of them shaped, optionally just one host's."""
|
|
rows = cert_document(client)["agents"]
|
|
for row in rows:
|
|
assert set(row) == CERT_AGENT_KEYS, sorted(row)
|
|
if name is None:
|
|
return rows
|
|
return [row for row in rows if row["name"] == name]
|
|
|
|
|
|
def cert_context(client: TestClient) -> Any:
|
|
"""The app's `AppContext`.
|
|
|
|
`TestClient.app` is typed as the ASGI callable it wraps, so the one cast lives here instead of
|
|
one `# type: ignore` at every assertion that needs the store or a job.
|
|
"""
|
|
return client.app.state.ctx # type: ignore[attr-defined]
|
|
|
|
|
|
def cert_meta(store: SampleStore) -> dict[str, str]:
|
|
"""Every `certs.*` row, for the before/after comparisons that prove a route wrote nothing.
|
|
|
|
Coalesced to `""` because the comparison is "did any byte of these five rows move", and an
|
|
absent row is the empty answer to that question rather than a second kind of nothing.
|
|
"""
|
|
return {key: store.get_meta(key) or "" for key in certs.META_KEYS}
|
|
|
|
|
|
def revoked_serials(bundle: str) -> list[str]:
|
|
"""The denylist of a bundle, as the agent reads it: the `# REVOKED <serial>` lines."""
|
|
return [line.removeprefix(certs.REVOKED_LINE).strip()
|
|
for line in bundle.splitlines() if line.startswith(certs.REVOKED_LINE)]
|
|
|
|
|
|
#: One PEM block out of a bundle, by text — the same read task 02's agent does with stdlib.
|
|
_PEM_BLOCK = re.compile(r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----\n", re.DOTALL)
|
|
|
|
|
|
def bundle_certs(bundle: str) -> list[x509.Certificate]:
|
|
"""The two certificates of a bundle, in the order the format writes them.
|
|
|
|
`[0]` is the agent's own server certificate and `[1]` the root it chains to (`certs.py`'s
|
|
`_bundle_text` order: metadata lines, agent cert, agent key, root cert). Reading them out of the
|
|
served bytes rather than trusting the response's echo is what makes "did the rotation reach the
|
|
artifact?" a checked claim.
|
|
"""
|
|
blocks = [block for block in _PEM_BLOCK.findall(bundle) if "CERTIFICATE" in block]
|
|
assert len(blocks) == 2, f"a bundle is two certificates and a key, got {len(blocks)}"
|
|
return [x509.load_pem_x509_certificate(block.encode()) for block in blocks]
|
|
|
|
|
|
def bundle_of(client: TestClient, name: str) -> str:
|
|
"""The served bundle, asserted to still be a bundle (2 certs, 1 key) before it is compared."""
|
|
response = client.get(f"/api/certs/{name}/bundle")
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("application/x-pem-file")
|
|
assert "no-store" in response.headers["cache-control"], "a private key may not be cached"
|
|
blocks = re.findall(r"-----BEGIN [A-Z0-9 ]+-----", response.text)
|
|
assert blocks.count("-----BEGIN CERTIFICATE-----") == 2, blocks
|
|
assert blocks.count("-----BEGIN PRIVATE KEY-----") == 1, blocks
|
|
return response.text
|
|
|
|
|
|
def stored_bundle(store: SampleStore, name: str) -> str:
|
|
"""The bundle exactly as the `certs.agents` row holds it."""
|
|
row: dict[str, Any] = json.loads(store.get_meta(certs.META_AGENTS) or "{}")
|
|
return str(row[name]["bundle"])
|
|
|
|
|
|
def cert_events(client: TestClient) -> list[dict[str, Any]]:
|
|
return client.get("/api/events", params={"source": "certs"}).json()["events"]
|
|
|
|
|
|
def cert_hosts(count: int) -> tuple[Target, ...]:
|
|
return DEAD_TARGETS[:count]
|
|
|
|
|
|
@pytest.mark.parametrize("hosts", [0, 1, 3], ids=["none", "one", "three"])
|
|
def test_the_cert_list_is_the_stored_table_for_zero_one_and_three_hosts(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, hosts: int) -> None:
|
|
"""The list works with no fleet at all, and says *unissued* rather than omitting a host.
|
|
|
|
The CA exists whether or not `HOST_TARGETS` does — a fresh database boots with a root and a
|
|
client certificate — so this is not the `/api/disks` "not configured" surface and does not 404.
|
|
A configured host with nothing issued still gets a row (invariant 3: a machine that is deployed
|
|
and not yet installed is not an absent machine), with `None` and not `0` where there is no
|
|
certificate (rule 2).
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(hosts),
|
|
host_interval=3600.0) as client:
|
|
rows = cert_agent_rows(client)
|
|
assert len(rows) == hosts, "one row per configured host, and no more"
|
|
assert [row["name"] for row in rows] == sorted(row["name"] for row in rows), (
|
|
"one order in the payload and one in the table")
|
|
for row in rows:
|
|
assert row["configured"] is True
|
|
assert row["status"] == "unissued", "nothing has been signed for it yet"
|
|
assert row["serial"] is None and row["bundle_rev"] is None, "no invented zeros"
|
|
assert row["deployed_bundle_rev"] is None, "nobody has answered, so nobody reported"
|
|
assert row["san"] == [], "no names on record, not a certificate with no names"
|
|
|
|
|
|
def test_the_cert_list_carries_the_rev_the_agent_reported(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`deployed_bundle_rev` is what the host said about itself, not what the CA hopes.
|
|
|
|
Task 05's "stale bundle" sentence is a comparison of two numbers that come from two different
|
|
places: the row this dashboard issued and the `agent` block of the last `system` payload. Both
|
|
halves are pinned here, including the direction that matters — issued 2 against deployed 1 is
|
|
the operator's "install the new bundle" case, and it has to be visible rather than smoothed
|
|
over.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=3600.0) as client:
|
|
assert client.post("/api/certs/deskwork/rotate").status_code == 200
|
|
job = cert_context(client).hub.jobs["host:deskwork"]
|
|
assert cert_agent_rows(client, "deskwork")[0]["deployed_bundle_rev"] is None, (
|
|
"a host that has never answered reports nothing")
|
|
job.state.payload = {"name": "deskwork", "agent": {"tls": True, "bundle_rev": 1}}
|
|
assert cert_agent_rows(client, "deskwork")[0]["deployed_bundle_rev"] == 1
|
|
assert client.post("/api/certs/deskwork/rotate").json()["bundle_rev"] == 2
|
|
row = cert_agent_rows(client, "deskwork")[0]
|
|
assert (row["bundle_rev"], row["deployed_bundle_rev"]) == (2, 1), "the stale pair, both"
|
|
# A token-mode agent publishes no `agent.tls` block at all, and that is `n/a`, not rev 0.
|
|
job.state.payload = {"name": "deskwork", "agent": {"version": "1.0.0"}}
|
|
assert cert_agent_rows(client, "deskwork")[0]["deployed_bundle_rev"] is None
|
|
|
|
|
|
def test_an_entry_for_a_host_that_left_the_config_is_listed_and_not_writable(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A row issued for a machine that has gone is a key still sitting in the table.
|
|
|
|
Listing it is the honest answer (`/certs` is where you learn you still hold one); refusing to
|
|
rotate or revoke it is the same invariant from the other side — `HOST_TARGETS` is what this
|
|
dashboard dials, and a name it no longer dials is not a name to install a bundle on. The row is
|
|
written straight to the store because that is the only way this state arises: a host removed
|
|
from the config, never a route that created it.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
certs.issue_agent_cert(store, certs.read_state(store), "old-host", ["10.9.8.7"])
|
|
|
|
rows = cert_agent_rows(client)
|
|
assert [row["name"] for row in rows] == ["deskwork", "old-host"]
|
|
ghost = rows[1]
|
|
assert ghost["configured"] is False, "listed, but this dashboard does not dial it"
|
|
assert ghost["status"] == certs.ACTIVE and ghost["bundle_rev"] == 1
|
|
assert ghost["deployed_bundle_rev"] is None, "no job means no report, not rev 0"
|
|
assert client.post("/api/certs/old-host/rotate").status_code == 404
|
|
assert client.post("/api/certs/old-host/revoke").status_code == 404
|
|
assert cert_events(client) == [], "a refusal is not an audit line"
|
|
|
|
|
|
def test_a_cert_route_for_an_unknown_name_dials_nothing_and_writes_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The invariant-3 gate on the per-host routes, taken before anything can have a side effect.
|
|
|
|
`not-installed` is refused in the first line of each route, so no client is built, no store row
|
|
is written, and no key is minted. All three are measured: the two network spies raise, `set_meta`
|
|
is watched for any `certs.*` write, and the five rows are compared byte for byte. A typo that
|
|
minted a certificate would be invisible on every status code and permanent in the table.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
calls: list[str] = []
|
|
written: list[str] = []
|
|
|
|
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
calls.append(f"GET {url}")
|
|
raise AssertionError(f"an unconfigured cert name dialled {url}")
|
|
|
|
def spy_fetch(*args: Any, **kwargs: Any) -> Any:
|
|
calls.append("fetch_snapshot")
|
|
raise AssertionError("an unconfigured cert name fetched an agent snapshot")
|
|
|
|
real_set_meta = SampleStore.set_meta
|
|
|
|
def spy_set_meta(self: SampleStore, key: str, value: str) -> None:
|
|
written.append(key)
|
|
real_set_meta(self, key, value)
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
|
|
monkeypatch.setattr("dashboards.collectors.fetch_snapshot", spy_fetch)
|
|
monkeypatch.setattr(SampleStore, "set_meta", spy_set_meta)
|
|
before = cert_meta(store)
|
|
|
|
for response in (client.get("/api/certs/not-configured/bundle"),
|
|
client.post("/api/certs/not-configured/rotate"),
|
|
client.post("/api/certs/not-configured/revoke")):
|
|
assert response.status_code == 404, response.text
|
|
body = response.json()
|
|
assert body["configured"] is False
|
|
assert "HOST_TARGETS" in body["detail"], "the sentence names the fix"
|
|
|
|
assert calls == [], "the gate is in front of the network, not behind it"
|
|
assert [key for key in written if key.startswith("certs.")] == [], "a write happened"
|
|
assert cert_meta(store) == before, "the stored CA and agent table did not move"
|
|
assert cert_events(client) == [], "and no audit line was written for a name that is not here"
|
|
# …and the same route still works for the host that *is* configured, so the refusal above
|
|
# is the gate and not a broken router.
|
|
assert client.post("/api/certs/deskwork/rotate").status_code == 200
|
|
|
|
|
|
def test_rotating_a_host_mints_the_bundle_it_asks_for(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Rotate is also *issue*: first call is `bundle_rev` 1, and the SAN is the config's address.
|
|
|
|
The SAN assertion is the one a rollout fails on silently — a certificate that does not name the
|
|
address the dashboard dials is a handshake error and a red source, not a warning — so it is read
|
|
out of the served bundle's own certificate rather than out of the response's echo.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
body = client.post("/api/certs/deskwork/rotate").json()
|
|
assert body["ok"] is True and body["status"] == certs.ACTIVE
|
|
assert body["bundle_rev"] == 1 and body["serial"] and body["not_after"] > time.time()
|
|
assert body["san"] == ["127.0.0.1", "deskwork"], "the dialled address and the name"
|
|
|
|
cert = bundle_certs(bundle_of(client, "deskwork"))[0]
|
|
names = cert.extensions.get_extension_for_class(
|
|
x509.SubjectAlternativeName).value.get_values_for_type(x509.IPAddress)
|
|
assert names == [ipaddress.ip_address("127.0.0.1")], "an IP SAN, or an IP dial fails"
|
|
assert cert.subject == x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "deskwork")])
|
|
|
|
events = cert_events(client)
|
|
assert len(events) == 1 and events[0]["kind"] == "cert-rotated"
|
|
assert events[0]["host"] == "deskwork" and events[0]["message"] == (
|
|
"agent certificate rotated for deskwork")
|
|
assert events[0]["data"] == {"bundle_rev": 1}
|
|
|
|
second = client.post("/api/certs/deskwork/rotate").json()
|
|
assert second["bundle_rev"] == 2 and second["serial"] != body["serial"]
|
|
# The new identity is not on the denylist: rotation replaces an identity, it does not strike
|
|
# one out, and a bundle that listed its own serial would refuse the client it ships.
|
|
bundle = bundle_of(client, "deskwork")
|
|
assert revoked_serials(bundle) == [], revoked_serials(bundle)
|
|
assert second["serial"] not in bundle.splitlines(), "no denylist line for the live serial"
|
|
assert stored_bundle(store, "deskwork") == bundle, "served bytes are the stored bytes"
|
|
|
|
|
|
def test_revoking_a_host_marks_the_table_and_the_next_poll_refuses_without_a_dial(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The API half of the revocation chain: this route is what task 03's refusal reads.
|
|
|
|
The control is the sentence from *before* the click (`unreachable`, which is what a dial to a
|
|
dead port says), and after it the poll has to say `agent certificate revoked` with a spy on
|
|
`httpx.AsyncClient.get` proving no socket was opened. The stored denylist is the other half: an
|
|
agent holding a bundle that omits the serial keeps accepting the client it was given.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=0.05, disks_interval=0.05) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
wait_for_source_error(client, "host:deskwork", "unreachable")
|
|
serial = client.post("/api/certs/deskwork/rotate").json()["serial"]
|
|
|
|
body = client.post("/api/certs/deskwork/revoke").json()
|
|
assert body["ok"] is True and body["status"] == certs.REVOKED
|
|
assert body["serial"] == serial, "revocation marks the identity it has, it does not replace"
|
|
assert body["hint"] and "next poll" in body["hint"]
|
|
|
|
calls: list[str] = []
|
|
|
|
async def spy_get(self: Any, url: Any, **kwargs: Any) -> Any:
|
|
calls.append(str(url))
|
|
raise AssertionError(f"a revoked agent was dialled at {url}")
|
|
|
|
monkeypatch.setattr(httpx.AsyncClient, "get", spy_get)
|
|
for key in ("host:deskwork", "disks:deskwork"):
|
|
error = wait_for_source_error(client, key, "agent certificate revoked")
|
|
assert "unreachable" not in error
|
|
assert calls == []
|
|
|
|
assert revoked_serials(bundle_of(client, "deskwork")) == [serial]
|
|
events = cert_events(client)
|
|
assert [row["kind"] for row in events] == ["cert-revoked", "cert-rotated"]
|
|
assert events[0]["message"] == "agent certificate revoked for deskwork"
|
|
assert certs.agent_status(store, "deskwork") == certs.REVOKED, (
|
|
"the collector's own read agrees with the row the route wrote")
|
|
|
|
|
|
def test_root_rotation_reissues_every_host_and_answers_restart_required(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""One action re-arms the fleet — and says the part it cannot do from inside the process.
|
|
|
|
`restart_required` is not decoration: the CA and the client pair are startup material, so the
|
|
table is new immediately while the sockets keep the old identity. The page (task 05) prints
|
|
that sentence because the API answered it, and the alternative is an operator who rotated a
|
|
root and watches a fleet that still chains to the previous one.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(3),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
for target in DEAD_TARGETS:
|
|
assert client.post(f"/api/certs/{target.name}/rotate").status_code == 200
|
|
before = cert_document(client)
|
|
rows_before = {row["name"]: row for row in before["agents"]}
|
|
root_pem_before = store.get_meta(certs.META_ROOT_CERT) or ""
|
|
key_before = store.get_meta(certs.META_ROOT_KEY) or ""
|
|
assert root_pem_before and key_before, "both halves of the CA exist before the rotation"
|
|
|
|
body = client.post("/api/certs/root/rotate").json()
|
|
assert body["ok"] is True and body["restart_required"] is True
|
|
assert body["agents"] == 3
|
|
assert body["root"]["serial"] != before["root"]["serial"], "a fresh root"
|
|
|
|
after = cert_document(client)
|
|
assert after["root"] == body["root"], "the list reports what the rotation wrote"
|
|
assert after["client"]["serial"] != before["client"]["serial"], "and a fresh client leaf"
|
|
for row in after["agents"]:
|
|
was = rows_before[row["name"]]
|
|
assert row["bundle_rev"] == was["bundle_rev"] + 1, "every host reissued, rev bumped"
|
|
assert row["serial"] != was["serial"] and row["status"] == certs.ACTIVE
|
|
bundle = bundle_of(client, row["name"])
|
|
leaf, root = bundle_certs(bundle)
|
|
assert leaf.serial_number == int(row["serial"]), "the served bundle is the listed one"
|
|
assert root.serial_number == int(after["root"]["serial"]), (
|
|
"and it chains to the authority the list now reports")
|
|
assert revoked_serials(bundle) == [], "the denylist resets with the generation"
|
|
assert store.get_meta(certs.META_ROOT_KEY) != key_before
|
|
assert store.get_meta(certs.META_ROOT_CERT) != root_pem_before
|
|
assert root_pem_before not in "".join(cert_meta(store).values()), (
|
|
"the old root is gone")
|
|
events = cert_events(client)
|
|
assert events[0]["kind"] == "cert-root-rotated"
|
|
assert events[0]["message"] == (
|
|
"root certificate rotated — every agent certificate reissued, restart required")
|
|
assert events[0]["data"] == {"agents": 3}
|
|
|
|
|
|
def test_the_bundle_route_names_a_file_and_changes_when_the_identity_does(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Byte-identical to the stored row, an `attachment`, and different bytes after a reissue.
|
|
|
|
This is the installer's input (task 06), so the claim is about bytes and not about a bundle
|
|
that merely re-verifies: `install-agent.sh` is handed the file and compares nothing else.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
missing = client.get("/api/certs/deskwork/bundle")
|
|
assert missing.status_code == 404, "nothing issued yet, so nothing to download"
|
|
body = missing.json()
|
|
assert body["configured"] is True and body["issued"] is False, "not the same 404"
|
|
assert "rotate" in body["detail"], "and the answer names the way to get one"
|
|
|
|
first = client.post("/api/certs/deskwork/rotate").json()["serial"]
|
|
response = client.get("/api/certs/deskwork/bundle")
|
|
assert response.headers["content-disposition"] == (
|
|
'attachment; filename="agent-deskwork.bundle"')
|
|
assert response.text == stored_bundle(store, "deskwork")
|
|
|
|
client.post("/api/certs/deskwork/rotate")
|
|
second = client.post("/api/certs/deskwork/rotate").json()["serial"]
|
|
assert second != first
|
|
updated = client.get("/api/certs/deskwork/bundle")
|
|
assert updated.text != response.text, "a reissue changes the served bytes"
|
|
assert updated.text == stored_bundle(store, "deskwork")
|
|
|
|
|
|
def test_no_certificate_material_leaves_meta_through_the_cert_surface(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""invariant 6 on the API that owns the keys: serials and dates go out, PEMs do not.
|
|
|
|
Every write here is exercised first, so the grep is over a table that has been rotated and
|
|
revoked rather than a pristine one, and it covers both the payloads a browser reads and the
|
|
stored event rows those payloads are built from — an event row is kept forever, which is what
|
|
makes a PEM in a message permanent rather than transient.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(2),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
client.post("/api/certs/deskwork/rotate")
|
|
client.post("/api/certs/driveripper/rotate")
|
|
client.post("/api/certs/driveripper/revoke")
|
|
client.post("/api/certs/root/rotate")
|
|
secrets = [value for value in cert_meta(store).values() if value]
|
|
assert any("PRIVATE KEY" in value for value in secrets), "the table really does hold keys"
|
|
|
|
for path in ("/api/certs", "/api/health", "/api/events", "/api/summary", "/api/config"):
|
|
text = client.get(path).text
|
|
for marker in ("BEGIN CERTIFICATE", "PRIVATE KEY", "BUNDLE-REV"):
|
|
assert marker not in text, f"{path} served {marker}"
|
|
assert client.get("/api/health").json()["db"], "health still answers, keys or no keys"
|
|
for event in store.recent_events(50, source="certs"):
|
|
assert "BEGIN" not in event.message and "PRIVATE KEY" not in event.message
|
|
assert not any(secret in json.dumps(event.as_json()) for secret in secrets)
|
|
|
|
|
|
def test_a_host_named_root_cannot_be_rotated_past_the_certificate_authority(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`/api/certs/root/rotate` and `/api/certs/{name}/rotate` are one URL for a host called root.
|
|
|
|
The CA route is registered first, so it wins — which means the wrong button would rotate the
|
|
whole authority instead of one host's leaf. Refusing with the reason is the only honest answer:
|
|
the alternative is a fleet-wide outage behind a click that looked like routine maintenance.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch,
|
|
host_targets=(Target(name="root", url="https://127.0.0.1:1"),),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
before = cert_document(client)["root"]["serial"]
|
|
refused = client.post("/api/certs/root/rotate")
|
|
assert refused.status_code == 409, refused.text
|
|
assert "rename" in refused.json()["detail"], "the sentence says what to do"
|
|
assert cert_document(client)["root"]["serial"] == before, "and the CA did not move"
|
|
assert cert_events(client) == []
|
|
assert cert_meta(store)[certs.META_AGENTS] == "{}", "nothing was issued for the name either"
|
|
|
|
|
|
def test_a_read_of_the_cert_surface_issues_nothing_and_writes_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A GET that mints a certificate is the surprise this route must never be.
|
|
|
|
`certs.ensure_certs` is the bootstrapping read and `certs.read_state` is the request-time one;
|
|
making `ensure_certs` explode is what proves the difference rather than asserting it in prose,
|
|
and the meta rows are compared byte for byte across three reads.
|
|
"""
|
|
with build_client(tmp_path, monkeypatch, host_targets=cert_hosts(1),
|
|
host_interval=3600.0) as client:
|
|
store: SampleStore = cert_context(client).store
|
|
before = cert_meta(store)
|
|
|
|
def explode(*args: Any, **kwargs: Any) -> Any:
|
|
raise AssertionError("a GET tried to bootstrap the certificate authority")
|
|
|
|
monkeypatch.setattr(certs, "ensure_certs", explode)
|
|
assert cert_agent_rows(client)
|
|
assert cert_document(client)["root"]["serial"] == str(
|
|
x509.load_pem_x509_certificate(
|
|
str(before[certs.META_ROOT_CERT]).encode()).serial_number)
|
|
# A bundle that has not been issued is still a 404 rather than a mint, on a read too.
|
|
assert client.get("/api/certs/deskwork/bundle").status_code == 404
|
|
assert cert_meta(store) == before, "three reads, no write"
|
|
assert cert_events(client) == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# phase 14 task 04: the endpoint budgets
|
|
#
|
|
# `store_perf.BUDGETS` is the one table of ceilings (it is what the probe's report and the notes
|
|
# render, so a ceiling relaxed in one place is relaxed everywhere). The ceilings are 2-3x the
|
|
# measured "after" on the seeded production-shaped store, which makes them a regression gate
|
|
# rather than a stopwatch: the `/system` page's own chart request costs ~2 ms and still gets
|
|
# 100 ms, while putting a full-table scan back on a per-request path crosses by three orders of
|
|
# magnitude. Wall time lives behind `--store-perf` for that reason; what runs in every `uv run
|
|
# pytest` is the key-name pin below, because a budget that silently stops being priced is as
|
|
# useless as a budget that is crossed.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_every_http_budget_names_a_route_the_probe_prices(tmp_path: Path) -> None:
|
|
"""The stale-pointer check for the HTTP half, on a throwaway store.
|
|
|
|
`measure_http` is what the priced test runs, and it owns the request shapes; if a key there is
|
|
renamed, or a route stops being measured, the priced test would report the gap rather than the
|
|
cost — which is why this asserts the key set here, cheaply, on every run.
|
|
"""
|
|
report = store_perf.measure_budget_http(tmp_path / "budget-keys.sqlite3",
|
|
hosts=("driveripper",), metrics=("cpu.usage_pct",))
|
|
assert set(report["http"]) == set(store_perf.HTTP_MEASURED_KEYS), (
|
|
"the HTTP pass and the budget table no longer describe the same requests")
|
|
assert store_perf.missing_budget_keys(report, ("http",)) == []
|
|
# and the request the budgets describe is the one the task names: 3 hosts x 9 series
|
|
nine = store_perf.HISTORY_METRICS
|
|
assert len(nine) == 9, "the 90 d budget is priced against a 3x9 request, not some other shape"
|
|
|
|
|
|
def test_metrics_stops_scanning_for_series_names_once_the_hub_is_polling(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The live shape behind one budgeted endpoint, guarded by statements rather than a clock.
|
|
|
|
Task 04's watch caught `/api/metrics` answering with its full `SELECT DISTINCT` scan on
|
|
practically every call of the running server (535 ms, measured): task 02's cache was invalidated
|
|
by every sample write, and the hub writes every 2-300 s, so the cache never lived long enough to
|
|
serve. The store-level half of this lives in `tests/test_store.py`; this is the half that says
|
|
the *endpoint* is what stops scanning, on the app the operator runs.
|
|
"""
|
|
db = str(tmp_path / "metrics-busy.sqlite3")
|
|
store = SampleStore(db)
|
|
store.add_many([Sample("host", "driveripper", "cpu.usage_pct", 1.0, time.time()),
|
|
Sample("host", "deskwork", "cpu.usage_pct", 1.0, time.time())])
|
|
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
|
pbkdf2_iterations=FAST_PBKDF2), store)
|
|
try:
|
|
with TestClient(app) as http:
|
|
sign_in(http)
|
|
assert "cpu.usage_pct" in http.get("/api/metrics", params={"source": "host"}).json()["metrics"]
|
|
|
|
# a poll of series the store already has, then the same request with the log open
|
|
store.add_many([Sample("host", "driveripper", "cpu.usage_pct", 2.0, time.time()),
|
|
Sample("host", "deskwork", "cpu.usage_pct", 2.0, time.time())])
|
|
seen: list[str] = []
|
|
# the statement log is the test: `SampleStore._db` is the handle the request runs on
|
|
store._db.set_trace_callback(seen.append)
|
|
try:
|
|
assert "cpu.usage_pct" in http.get("/api/metrics",
|
|
params={"source": "host"}).json()["metrics"]
|
|
finally:
|
|
store._db.set_trace_callback(None)
|
|
assert [s for s in seen if "DISTINCT metric" in s] == [], (
|
|
f"`/api/metrics` went back to the table while nothing changed: {seen}")
|
|
|
|
# and a source that starts publishing something new is in the very next answer
|
|
store.add("host", "driveripper", "gpu.busy_pct@0", 1.0)
|
|
assert "gpu.busy_pct@0" in http.get("/api/metrics",
|
|
params={"source": "host"}).json()["metrics"]
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
@pytest.mark.store_perf
|
|
def test_the_budgeted_http_paths_fit_their_pinned_margins(perf_store_path: Path) -> None:
|
|
"""`/api/health`, `/api/metrics`, `/api/system/history` (1 h and 90 d), `/api/summary`.
|
|
|
|
Priced through the real ASGI gate with a signed-in session, on the seeded 6.4 M-row store —
|
|
the same call the operator's browser makes, in-process so no network is measured. This is the
|
|
line `TODO.md:7` is about: `/api/health` used to answer in 1.27 s because it counted the whole
|
|
`sample` table on every poll, on every tab.
|
|
"""
|
|
report = store_perf.measure_budget_http(perf_store_path)
|
|
print()
|
|
print(store_perf.render_budgets_markdown(store_perf.budget_rows(report, ("http",))))
|
|
assert store_perf.budget_violations(report, ("http",)) == []
|