"""Tests for the admin frontend serving route GET /admin/.
The frontend is served as a single FileResponse (no StaticFiles mount). Its
path is derived from ``Path(__file__).resolve().parent`` inside ``admin.py`` —
not from ``base_dir`` — so it resolves to the real repo file even when the
router is built without a base_dir, mirroring the daashbrd H3 portability fix.
"""
from __future__ import annotations
import os
import re
import sqlite3
import subprocess
import sys
from pathlib import Path
import pytest
from starlette.testclient import TestClient
import admin
import dispatcher
from config import load_config
ROOT = Path(__file__).resolve().parent.parent
CFG = load_config(str(ROOT / "config" / "config.yaml"))
def _function_body(html: str, name: str) -> str:
"""One JS function's source, so an assertion cannot match another card.
Several dashboard cards share the same bar markup, so a bare substring
check against the whole file silently passes on the wrong one.
"""
match = re.search(rf"^function {re.escape(name)}\(.*?^\}}", html, re.S | re.M)
assert match, f"{name}() not found in index.html"
return match.group(0)
@pytest.fixture
def admin_client(tmp_path, monkeypatch):
"""The real dispatcher app, which mounts admin at the /admin prefix."""
db_path = str(tmp_path / "admin.db")
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript((ROOT / "config" / "schema.sql").read_text())
conn.close()
monkeypatch.setattr(dispatcher.cfg.database, "path", db_path)
monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False)
monkeypatch.setattr(dispatcher.cfg.routing, "require_vision", False)
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
with TestClient(dispatcher.app) as client:
yield client
def test_admin_index_returns_html_with_chartjs(admin_client):
"""GET /admin/ returns 200, text/html, and contains the Chart.js tag."""
resp = admin_client.get("/admin/")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert "chart.js" in resp.text
def test_admin_index_file_exists_at_module_derived_path():
"""The served file lives at Path(__file__).parent/admin/frontend/index.html."""
expected = (
Path(admin.__file__).resolve().parent.parent / "admin" / "frontend" / "index.html"
)
assert expected.is_file(), f"frontend file missing at {expected}"
def test_admin_frontend_path_does_not_depend_on_base_dir():
"""build_router WITHOUT base_dir already serves '/' on the raw router.
The route must derive its file path from admin.py's own location, not from
the optional base_dir argument, so it stays portable and testable.
"""
probe = (
"import sqlite3\n"
"import admin\n"
"from fastapi import FastAPI\n"
"cfg = admin.load_config('config/config.yaml')\n"
"def db():\n"
" c = sqlite3.connect(':memory:')\n"
" c.row_factory = sqlite3.Row\n"
" return c\n"
"app = FastAPI()\n"
"app.include_router(admin.build_router(cfg, db))\n"
"from starlette.testclient import TestClient\n"
"with TestClient(app) as client:\n"
" resp = client.get('/')\n"
" assert resp.status_code == 200, resp.status_code\n"
" assert resp.headers['content-type'].startswith('text/html')\n"
)
subprocess.run(
[sys.executable, "-c", probe],
check=True,
cwd=str(ROOT),
capture_output=True,
env={**os.environ, "PYTHONPATH": str(ROOT / "src")},
)
def test_admin_unknown_api_returns_json_404_not_html(admin_client):
"""GET /admin/api/nonexistent is a 404 JSON body, not the HTML page."""
resp = admin_client.get("/admin/api/nonexistent")
assert resp.status_code == 404
assert resp.headers["content-type"].startswith("application/json")
assert "chart.js" not in resp.text
def test_admin_controls_returns_html_with_controls_marker(admin_client):
"""GET /admin/controls returns 200, text/html, and contains the controls
section marker (Operational Triggers)."""
resp = admin_client.get("/admin/controls")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert "Operational Triggers" in resp.text
# The save button's label is rendered from the dirty-row count, so assert
# on the element it hangs off rather than on its text.
assert "Persisted Config" in resp.text
assert 'id="config-save-btn"' in resp.text
def test_admin_controls_has_a_dedicated_classifier_card(admin_client):
"""classifier.mode gets its own card, not a row in the generic Runtime
Knobs list -- its cross-field structure (cloud_llm needs a primary,
local_encoder needs a model) can't be represented as one scalar."""
resp = admin_client.get("/admin/controls")
text = resp.text
assert 'id="classifier-mode-select"' in text
assert 'id="classifier-config-error"' in text
assert "onClassifierCloudAutoToggle" in text
# The bug this pins: toggling auto must NOT call the full-regenerate
# handler, which discarded the checkbox state it had just set.
assert 'onchange="onClassifierCloudAutoToggle()"' in text
# The second bug this pins: a blank base_url/model must be sent as
# null, not {"base_url": "", "model": ""} -- an object with keys is
# truthy, so the backend's own "cloud_primary is required" check would
# not have caught it (this was live-verified in a browser: it silently
# saved before this line existed).
assert "(baseUrl && model) ? { base_url: baseUrl, model } : null" in text
def test_controls_page_carries_the_candidate_labels_readout(admin_client):
"""The classifier card shows what it may return, and what it may not.
candidate_categories is a structural list, so the scalar-knob tripwire
never reaches it; this readout is the visible answer to "why does nothing
classify as X any more" -- and the excluded half names the categories that
stop accumulating outcomes. Rendered with textContent throughout: config
strings are data, not markup.
"""
text = admin_client.get("/admin/controls").text
assert 'id="classifier-candidate-categories"' in text
assert "renderClassifierCandidateCategories(data)" in text
assert "labels the classifier may return: " in text
assert "excluded from the scoring axis, so they stop accumulating outcomes: " in text
def test_admin_controls_confidence_min_field_is_percent_with_conversion(admin_client):
"""The encoder confidence_min field takes 0-100 (a human types "80"
meaning 80%) and converts to the 0.0-1.0 raw similarity score
classify_zero_shot actually returns -- typing the intuitive percent
value used to be stored literally under the old field name
``confidence_threshold``, so no real confidence score could ever clear
the threshold and every classification silently failed (live 2026-09-06)."""
resp = admin_client.get("/admin/controls")
text = resp.text
assert 'id="classifier-encoder-threshold"' in text
assert 'max="100"' in text
assert "parseFloat(thresholdPct) / 100" in text
# Loading back a stored 0.0-1.0 value must display it as a percent.
assert "Math.round(enc.confidence_min * 100)" in text
def test_admin_models_returns_html_with_availability_marker(admin_client):
"""GET /admin/models returns 200, text/html, and contains the Model
Availability card title."""
resp = admin_client.get("/admin/models")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert "Model Availability" in resp.text
def test_admin_models_hides_deprecated_by_default_but_can_show_them(admin_client):
"""The models page defaults to hiding deprecated/stale rows (e.g. after
the OpenRouter allowlist prunes 425 down to 30) but can reveal them.
Asserted as a property rather than as a specific widget. This previously
pinned `id="show-deprecated-toggle"`, and when that all-or-nothing switch
became an availability select -- which can also isolate *only* the stale
rows, which is what auditing an override needs -- the test failed for a
change that preserved everything it was protecting. See
tests/test_models_page_structure.py for the filter surface itself.
"""
text = admin_client.get("/admin/models").text
# Default view excludes the two non-routable states...
assert "'deprecated'" in text and "'stale'" in text
assert 'value="routable"' in text
# ...and there is a control that puts them back.
assert 'id="mf-availability"' in text
assert ">All statuses<" in text
def test_admin_profiles_returns_html_with_profiles_marker(admin_client):
"""GET /admin/profiles returns 200, text/html, and contains the Chart.js
tag and the Profiles page marker."""
resp = admin_client.get("/admin/profiles")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert "chart.js" in resp.text
assert "Profiles" in resp.text
def test_admin_pages_include_profiles_nav_link(admin_client):
"""Every served admin page body contains the mount the shared nav renders into.
The nav links are built client-side from navbar.js's NAV_LINKS (the
link list's completeness is owned by tests/test_admin_nav_is_complete.py),
so served HTML ships the empty
the script fills
rather than the Profiles anchor itself.
"""
for path in ["/admin/", "/admin/models", "/admin/profiles", "/admin/decisions", "/admin/controls"]:
resp = admin_client.get(path)
assert resp.status_code == 200
assert 'id="nav-links"' in resp.text, f"nav-links mount missing on {path}"
def test_quota_page_returns_html_with_quota_marker(admin_client):
"""GET /admin/quota returns 200, text/html, and contains the Quota page marker."""
resp = admin_client.get("/admin/quota")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
assert "Quota" in resp.text
assert "chart.js" in resp.text
assert "renderQuotaChip" not in resp.text
def test_quota_api_returns_accounts_shape(admin_client):
"""GET /admin/api/quota returns the quota_accounts shape with period, accounts, spend, alarm."""
resp = admin_client.get("/admin/api/quota")
assert resp.status_code == 200
data = resp.json()
assert "period" in data
assert "accounts" in data
assert "spend" in data
assert data["spend"]["total_usd"] >= 0
def test_the_home_page_carries_no_quota_chip():
"""The navbar chip is gone, deliberately.
It restated one number the Quota tile already shows, on every page, in
the one strip of the layout that has to stay legible at any width. The
tile links to the same place. Asserted as an absence so the chip cannot
quietly return with the next navbar edit.
"""
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
# The href attribute is built client-side now: the nav renders from
# navbar.js and the board tiles from tile('/admin/quota', ...). The
# static trace of both is the pathname itself.
assert "/admin/quota" in html, "the Quota tile still has to link there"
assert "renderQuotaChip" not in html
assert "quota-chip-link" not in html
def test_quota_chip_has_no_modal_references():
"""The chip no longer opens a modal — no data-bs-toggle, quota-modal or renderQuotaModal."""
index_path = ROOT / "admin" / "frontend" / "index.html"
html = index_path.read_text()
assert "quota-modal" not in html
assert "renderQuotaModal" not in html
assert "formatProviderBalance" not in html
assert "formatBalanceStaleness" not in html
def test_builtin_profile_cards_offer_duplicate(admin_client):
"""Built-ins had no action buttons at all, so the page was five locked cards.
Duplicate is the path AROUND the read-only rule (a new config profile
pre-filled from the built-in's definition), not through it — the edit and
delete branch must stay behind ``!isBuiltin``.
"""
text = admin_client.get("/admin/profiles").text
assert 'data-action="duplicate"' in text
assert "openDuplicateModal" in text
# Still no edit/delete offered for a built-in. The true branch of the
# ternary is one template literal, so it runs to the next backtick.
branch = re.search(r"const actions = isBuiltin\s*\?\s*`([^`]*)`", text)
assert branch is not None, "the isBuiltin actions ternary was restructured"
builtin_branch = branch.group(1)
assert 'data-action="duplicate"' in builtin_branch
assert 'data-action="delete"' not in builtin_branch
assert 'data-action="edit"' not in builtin_branch
def test_profiles_page_renders_category_coverage(admin_client):
"""The bare "admits 0 models" number is no longer the whole story."""
text = admin_client.get("/admin/profiles").text
assert "category_coverage" in text
assert "for an unspecified category" in text
assert "Category-gated" in text
def test_controls_page_explains_gaming_modes_cloud_requirement(admin_client):
"""The refusal is the feature, so the page has to say what it requires."""
text = admin_client.get("/admin/controls").text
assert "local-compute-toggle" in text
assert "classifier.cloud_fallback" in text
# And why the free cascade steps still run ahead of the paid one.
assert "route_decisions" in text
def test_controls_page_warns_next_to_the_prefix_probe_toggle(admin_client):
"""pinch.prefix_probe is the one knob whose only effect is on measurement.
A bare switch in a column of switches says nothing about that, and turning
it off costs the evidence the context-pruning rewrite is waiting on. The
note must be reachable from BOTH panels -- the runtime knob name and the
persisted-config key -- because the knob appears in both.
"""
text = admin_client.get("/admin/controls").text
assert "off stops the cache-loss measurement" in text
assert "pinch_prefix_probe:" in text
assert "'pinch.prefix_probe':" in text
# Rendered into the meta track of both lists, not as a new visual pattern.
assert "noteBadge(key)" in text
def test_controls_page_pairs_the_incumbent_gate_with_its_dial(admin_client):
"""The two Wave 2 knobs are two rows that only mean anything together.
A bare switch beside a bare number field says nothing about that, and the
house style forbids answering it with a paragraph above the list. So each
row carries one short note, reachable from BOTH panels -- the runtime knob
name and the persisted-config key.
"""
text = admin_client.get("/admin/controls").text
assert "enables the challenger dial" in text
assert "blank = neutral, 0 = full penalty" in text
for key in (
"incumbent_cache_pricing:",
"'objective.incumbent_cache_pricing':",
"incumbent_challenger_cache_rate:",
"'objective.incumbent_challenger_cache_rate':",
):
assert key in text
def test_controls_page_dial_is_a_bounded_number_field_that_blanks_to_null(
admin_client,
):
"""Blank must post null, not 0 -- they are opposite ends of this dial.
``Number('')`` is 0 in JavaScript, so the obvious one-liner turns "clear
the field to switch this off" into "set the maximum penalty". The guard is
the explicit empty-string test in the onchange handler, and the 0-1 bounds
are what keep a percentage (the confidence_threshold incident's raw 80)
off the field in the first place.
"""
text = admin_client.get("/admin/controls").text
assert "NUMBER_BOUNDS" in text
assert "incumbent_challenger_cache_rate: { min: 0, max: 1" in text
assert "placeholder: 'neutral'" in text
assert "this.value.trim() === '' ? null : Number(this.value)" in text
# And the number branch is reached at all: renderRuntime used to fall
# through to a read-only muted span for anything that was not a bool,
# an enum or a string.
assert "typeof persisted === 'number'" in text
def test_controls_page_pairs_the_session_cache_switch_with_its_window(admin_client):
"""The switch and the window it gates are one decision in two rows.
A bare number field beside a bare switch says nothing about that, and the
house style forbids answering it with a paragraph above the list. So each
row carries one short note, keyed from BOTH panels — the runtime knob name
and the persisted-config key.
"""
text = admin_client.get("/admin/controls").text
assert "enables the reuse window" in text
assert "how long one label steers routing" in text
for key in (
"session_cache_enabled:",
"'session_cache.enabled':",
"session_cache_staleness_seconds:",
"'session_cache.staleness_seconds':",
):
assert key in text
def test_controls_page_staleness_window_is_bounded_and_has_no_blank(admin_client):
"""5 to 7200, and no placeholder — blank means nothing on this field.
The dial above uses `placeholder` to say what an empty field means, so its
absence here is the signal that emptiness is not a setting. The floor is 5
rather than 0 because 0 reads as "off" and is not: the cache still writes
and the classifier-failure cascade still replays the entry.
"""
text = admin_client.get("/admin/controls").text
assert "session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 }" in text
assert "this.value.trim() === '' ? null : Number(this.value)" in text
def test_controls_panels_state_when_each_mechanism_takes_effect(admin_client):
"""Runtime is immediate, persisted needs the bounce. One clause each."""
text = admin_client.get("/admin/controls").text
assert "Live on the next request" in text
assert "Applied on restart" in text
def test_admin_controls_persisted_config_handles_wrapped_value_shape(admin_client):
"""GET /admin/controls contains the updated Persisted Config hint and the
frontend unwrapping logic for the {value, source} response shape."""
resp = admin_client.get("/admin/controls")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/html")
text = resp.text
assert "config/config.local.yaml" in text
assert "entry.value" in text
assert "source === 'overlay'" in text
assert "data-orig" in text
def test_admin_controls_has_cloud_fallback_card_ids(tmp_path, admin_client):
"""classifier.cloud_fallback gets its own card in admin/controls.
The card has id="cloud-fallback-state", cf-base-url input,
cf-model input, and a saveCloudFallback() button."""
resp = admin_client.get("/admin/controls")
text = resp.text
# Card id
assert 'id="cloud-fallback-state"' in text
# Input ids
assert 'id="cf-base-url"' in text
assert 'id="cf-model"' in text
# Save button onclick
assert 'onclick="saveCloudFallback()"' in text
# Card title
assert "Cloud Fallback" in text
# Error container
assert 'id="cloud-fallback-error"' in text
def test_frontend_version_returns_a_digest(admin_client):
"""GET /admin/api/frontend-version is what an open page polls to notice the
frontend under it changed."""
resp = admin_client.get("/admin/api/frontend-version")
assert resp.status_code == 200
digest = resp.json()["digest"]
assert isinstance(digest, str) and digest
# Nothing moved between the two calls, so neither did the digest.
assert admin_client.get("/admin/api/frontend-version").json()["digest"] == digest
def test_frontend_digest_is_stable_and_moves_with_mtime_or_size(tmp_path):
"""Stable when nothing changes; different when mtime or size does.
Both halves matter. A digest that drifts on its own cries wolf on every
poll until the operator learns to ignore the notice, and a digest that
misses a same-size edit is exactly the hand-edit-during-iteration case this
exists for -- which is why it carries st_mtime_ns and not just st_size.
"""
page = tmp_path / "index.html"
page.write_text("one")
paths = (page,)
first = admin.frontend_digest(paths)
assert admin.frontend_digest(paths) == first
# Same length, different content: only the mtime distinguishes them.
stat = page.stat()
page.write_text("two")
os.utime(page, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
mtime_changed = admin.frontend_digest(paths)
assert mtime_changed != first
# Same mtime, different size.
page.write_text("three")
os.utime(page, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000))
assert admin.frontend_digest(paths) != mtime_changed
def test_frontend_digest_survives_a_missing_file(tmp_path):
"""A missing file must not raise -- the notice is a convenience, and a
partial checkout is not a reason to 500 an admin page."""
present = tmp_path / "index.html"
present.write_text("x")
absent = tmp_path / "gone.html"
missing = admin.frontend_digest((present, absent))
assert isinstance(missing, str) and missing
# And when the file comes back, the digest says so.
absent.write_text("y")
assert admin.frontend_digest((present, absent)) != missing
def test_frontend_digest_covers_every_served_frontend_file():
"""The tripwire: a new admin page must join the digest set.
If it does not, the one file that changed is invisible to the poll and a
browser sitting on that page keeps running its old copy with nothing on
screen saying so -- the precise failure the notice was built for, reopened
by adding a page.
"""
directory = ROOT / "admin" / "frontend"
on_disk = {
p.name
for p in directory.iterdir()
if p.is_file() and p.name != "__init__.py"
}
covered = {p.name for p in admin.frontend_paths(ROOT)}
assert covered == on_disk, (
"admin._FRONTEND_FILES and admin/frontend/ disagree; "
f"missing from the digest: {sorted(on_disk - covered)}, "
f"listed but absent: {sorted(covered - on_disk)}"
)
def test_navbar_offers_a_reload_and_never_takes_one():
"""The notice is one short line with a reload the operator chooses.
Auto-reloading would discard whatever is half-typed in a profile modal or a
dirty config row, which is a worse outcome than the stale page. The reload
call therefore has to sit inside a click handler and nowhere else.
"""
js = (ROOT / "admin" / "frontend" / "navbar.js").read_text()
assert "frontend-version" in js
assert "This page is out of date" in js
reloads = re.findall(r"window\.location\.reload\(\)", js)
assert len(reloads) == 1, "exactly one reload, and it belongs to the button"
handler = re.search(
r"nav-stale-reload'\)\.addEventListener\('click', function \(\) \{\s*"
r"window\.location\.reload\(\);",
js,
)
assert handler, "the only reload must be the Reload button's own handler"
def test_navbar_stale_notice_cannot_shift_the_page():
"""Fixed, and hidden until it fires.
In the navbar cluster it would shove the status dot and the profile
switcher sideways on appearing, and it would scroll out of view with the
header while the reason to reload stayed true.
"""
css = (ROOT / "admin" / "frontend" / "navbar.css").read_text()
block = re.search(r"^\.nav-stale\{(.*?)\}", css, re.S | re.M)
assert block, ".nav-stale rule not found"
assert "position:fixed" in block.group(1)
assert "display:none" in block.group(1)
assert ".nav-stale.show{display:flex}" in css
def test_the_home_page_no_longer_renders_a_verdict_bar():
"""The verdict-mix card left the home page with the Board rebuild.
Keeping the reasoning, because it cost a browser session to find and the
next verdict chart will hit it again: `unverifiable` is not a finding. It
is the default outcome for prose and for any turn ending in a tool call,
so it is structurally the largest bucket by a wide margin -- 13,347
against 870 for the next-largest over 7 days of real traffic. Any bar
scaled against it renders every meaningful verdict at the 2px minimum.
Exclude it from the SCALE, never from the denominator: the share is what
tells you most traffic is unverifiable.
The home page now states the same fact as one ratio in the Live rail's
Signal quality card, which needs no scale at all.
"""
html = (ROOT / "admin" / "frontend" / "index.html").read_text()
assert "VERDICT_OFF_SCALE" not in html
assert "renderVerdict" not in html
assert "failing, last 7 days" in html, "the fact itself still has to be on the page"
def _classifier_mode_select_html() -> str:
"""The classifier card's mode