"""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_threshold_field_is_percent_with_conversion(admin_client): """The encoder confidence_threshold field takes 0-100 (a human types "80" meaning 80%) and converts to the 0.0-1.0 probability classify_zero_shot actually returns -- typing the intuitive percent value used to be stored literally, 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_threshold * 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 Profiles nav link markup.""" for path in ["/admin/", "/admin/models", "/admin/profiles", "/admin/decisions", "/admin/controls"]: resp = admin_client.get(path) assert resp.status_code == 200 assert ">Profiles<" in resp.text, f"Profiles nav link 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() assert 'href="/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"