Validate umbrella graph WebUI proof
Some checks failed
plugin-tests / test (push) Failing after 5s
upstream-drift / drift (push) Failing after 5s

This commit is contained in:
Svrnty 2026-05-25 12:57:39 -04:00
parent b75fbf48ae
commit 4ad595506a
6 changed files with 293 additions and 11 deletions

View File

@ -26,10 +26,15 @@ PLUGIN_REPO = Path(__file__).resolve().parent.parent
# Endpoints we expect after the plugin is loaded. Status codes and content
# checks are minimal — this is "did it boot", not "is it correct".
SMOKE = [
{"path": "/healthz", "expect": [200], "kind": "vanilla"},
{"path": "/health", "expect": [200], "kind": "vanilla"},
{"path": "/api/vault/status", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/plugins/svrnty/app.css", "expect": [200], "kind": "plugin-static"},
{"path": "/plugins/svrnty/app.js", "expect": [200], "kind": "plugin-static"},
{"path": "/api/umbrella", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/api/umbrella/doc?path=sot/README.md", "expect": [200, 401, 403], "kind": "plugin"},
{"path": "/plugins/svrnty/app.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/app.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/umbrella.html", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/umbrella.css", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
{"path": "/plugins/svrnty/umbrella.js", "expect": [200, 302, 401, 403], "kind": "plugin-static"},
]
@ -100,17 +105,23 @@ def main():
base = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env["HERMES_WEBUI_PYTHON_PLUGIN"] = "svrnty_hermes_webui_plugin"
env["PORT"] = str(port)
env["HERMES_WEBUI_PORT"] = str(port)
env["HERMES_REPO_ROOT"] = str(PLUGIN_REPO.parent)
env["PYTHONPATH"] = (
str(PLUGIN_REPO)
if not env.get("PYTHONPATH")
else f"{PLUGIN_REPO}{os.pathsep}{env['PYTHONPATH']}"
)
# Best-effort: start under the agent venv if it exists; else system python.
py = Path(args.webui_dir) / "venv" / "bin" / "python"
cmd = [str(py) if py.exists() else "python3", "bootstrap.py", "--foreground"]
cmd = [str(py) if py.exists() else "python3", "bootstrap.py", str(port), "--foreground"]
proc = subprocess.Popen(
cmd, cwd=args.webui_dir, env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid,
)
if not _wait_for(f"{base}/healthz", timeout=30):
print(f"FAIL: webui did not respond at {base}/healthz within 30s",
if not _wait_for(f"{base}/health", timeout=30):
print(f"FAIL: webui did not respond at {base}/health within 30s",
file=sys.stderr)
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
sys.exit(1)

View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Browser visual smoke for the Cortex-OS umbrella panel.
Starts hermes-webui with the Svrnty plugin against a temporary auth-free state
dir, opens the real /plugins/svrnty/umbrella.html page, waits for the graph to
render, captures screenshots, and records simple pixel statistics.
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import tempfile
import time
import warnings
from datetime import datetime
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
from PIL import Image
from playwright.sync_api import sync_playwright
PLUGIN_REPO = Path(__file__).resolve().parent.parent
HERMES_REPO = PLUGIN_REPO.parent
WEBUI_REPO = HERMES_REPO / "hermes-webui"
AGENT_REPO = HERMES_REPO / "hermes-agent"
DEFAULT_OUT = HERMES_REPO / "sot" / "08-OUTPUTS" / "umbrella-visual-proof-2026-05-25.json"
def _free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _wait_for(url: str, timeout: int = 30) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urlopen(url, timeout=1) as res:
if res.status < 500:
return True
except URLError:
pass
except Exception:
pass
time.sleep(0.25)
return False
def _pixel_stats(path: Path) -> dict:
img = Image.open(path).convert("RGB")
width, height = img.size
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
pixels = list(img.getdata())
total = len(pixels)
dark_bg = sum(1 for r, g, b in pixels if r < 28 and g < 32 and b < 42)
bright = sum(1 for r, g, b in pixels if max(r, g, b) > 120)
distinct = len(set(pixels[:: max(1, total // 20000)]))
return {
"path": str(path),
"width": width,
"height": height,
"dark_bg_ratio": round(dark_bg / total, 4),
"bright_ratio": round(bright / total, 4),
"sampled_distinct_colors": distinct,
"nonblank": bright > 500 and distinct > 20,
}
def _start_webui(port: int, state_dir: Path) -> subprocess.Popen:
env = os.environ.copy()
env.pop("HERMES_WEBUI_PASSWORD", None)
env["HERMES_WEBUI_PYTHON_PLUGIN"] = "svrnty_hermes_webui_plugin"
env["HERMES_WEBUI_PORT"] = str(port)
env["HERMES_WEBUI_STATE_DIR"] = str(state_dir)
env["HERMES_WEBUI_AGENT_DIR"] = str(AGENT_REPO)
env["HERMES_REPO_ROOT"] = str(HERMES_REPO)
env["PYTHONPATH"] = (
f"{PLUGIN_REPO}{os.pathsep}{AGENT_REPO}"
if not env.get("PYTHONPATH")
else f"{PLUGIN_REPO}{os.pathsep}{AGENT_REPO}{os.pathsep}{env['PYTHONPATH']}"
)
py = WEBUI_REPO / "venv" / "bin" / "python"
agent_py = AGENT_REPO / "venv" / "bin" / "python"
python_exe = str(py) if py.exists() else str(agent_py) if agent_py.exists() else "python3"
cmd = [python_exe, str(WEBUI_REPO / "server.py")]
return subprocess.Popen(
cmd,
cwd=AGENT_REPO if AGENT_REPO.exists() else WEBUI_REPO,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
preexec_fn=os.setsid,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output-json", default=str(DEFAULT_OUT))
parser.add_argument("--screenshot-dir", default=str(HERMES_REPO / "screenshots" / "umbrella"))
args = parser.parse_args()
out_json = Path(args.output_json)
screenshot_dir = Path(args.screenshot_dir)
screenshot_dir.mkdir(parents=True, exist_ok=True)
port = _free_port()
base = f"http://127.0.0.1:{port}"
proc: subprocess.Popen | None = None
server_log = ""
with tempfile.TemporaryDirectory(prefix="hermes-webui-umbrella-") as tmp:
proc = _start_webui(port, Path(tmp))
try:
if not _wait_for(f"{base}/health", timeout=30):
if proc.stdout:
server_log = proc.stdout.read(4000)
raise RuntimeError(f"webui did not become healthy at {base}/health")
proof = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"base": base,
"viewports": [],
}
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
executable_path="/usr/bin/google-chrome" if Path("/usr/bin/google-chrome").exists() else None,
args=["--no-sandbox"],
)
for label, size in (
("desktop", {"width": 1440, "height": 1000}),
("mobile", {"width": 390, "height": 844}),
):
page = browser.new_page(viewport=size)
page.goto(f"{base}/plugins/svrnty/umbrella.html", wait_until="networkidle", timeout=30000)
page.wait_for_function(
"() => window.__svrntyUmbrella && window.__svrntyUmbrella.ready === true",
timeout=30000,
)
state = page.evaluate("() => window.__svrntyUmbrella")
stats_text = page.locator("#stats").inner_text(timeout=5000)
assert "nodes" in stats_text and "edges" in stats_text
shot = screenshot_dir / f"umbrella-{label}-2026-05-25.png"
page.screenshot(path=str(shot), full_page=True)
pixels = _pixel_stats(shot)
proof["viewports"].append({
"label": label,
"viewport": size,
"state": state,
"stats_text": stats_text,
"pixel_stats": pixels,
})
page.close()
browser.close()
proof["pass"] = all(v["state"]["nodes"] > 0 and v["state"]["edges"] > 0 and v["pixel_stats"]["nonblank"] for v in proof["viewports"])
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps(proof, indent=2), encoding="utf-8")
print(json.dumps(proof, indent=2))
return 0 if proof["pass"] else 1
finally:
if proc is not None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=5)
except Exception:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
if server_log:
print(server_log, file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -31,11 +31,14 @@
consumes: { color: "#6ee7b7", style: "solid" },
produces: { color: "#a78bfa", style: "dashed" },
supersedes: { color: "#f87171", style: "dashed" },
cites: { color: "#38bdf8", style: "dotted" },
derives_from: { color: "#f59e0b", style: "dashed" },
};
let cy = null;
let graph = null;
const activeTypes = new Set();
window.__svrntyUmbrella = { ready: false, error: null, nodes: 0, edges: 0 };
async function loadGraph() {
const res = await fetch("/api/umbrella", { cache: "no-store" });
@ -265,8 +268,15 @@
renderFilters(graph);
renderGraph(graph);
bindControls();
window.__svrntyUmbrella = {
ready: true,
error: null,
nodes: graph.nodes.length,
edges: graph.edges.length,
};
} catch (e) {
document.getElementById("stats").textContent = "load failed: " + e.message;
window.__svrntyUmbrella = { ready: false, error: String(e.message || e), nodes: 0, edges: 0 };
console.error("[umbrella]", e);
}
}

View File

@ -44,7 +44,7 @@ def test_loader_exposes_seven_method_contract(loader):
def test_loader_register_wires_our_plugin(loader, monkeypatch):
"""End-to-end: env var → import this plugin → register() fires our 2 routes + processor."""
"""End-to-end: env var → import this plugin → register() fires plugin routes + processor."""
monkeypatch.setenv("HERMES_WEBUI_PYTHON_PLUGIN", "svrnty_hermes_webui_plugin")
# Reset loader idempotency guard so we can re-run in-process
loader._LOADED = False
@ -57,9 +57,11 @@ def test_loader_register_wires_our_plugin(loader, monkeypatch):
sys.path.insert(0, str(PLUGIN_REPO))
loader.load_plugin()
# Routes registered: /api/transcribe (POST) + /api/vault/status (GET)
# Core routes registered, including the /umbrella graph API pair.
assert ("POST", "/api/transcribe") in loader._ROUTES
assert ("GET", "/api/vault/status") in loader._ROUTES
assert ("GET", "/api/umbrella") in loader._ROUTES
assert ("GET", "/api/umbrella/doc") in loader._ROUTES
# Static + injected URLs
assert "svrnty" in loader._STATIC
assert "/plugins/svrnty/app.css" in loader._STYLESHEETS

View File

@ -26,5 +26,5 @@ def test_plugin_registers_static_and_injects_assets():
api.logger.return_value = MagicMock()
plg.register(api)
api.register_static.assert_called()
api.inject_stylesheet.assert_called_with("/plugins/svrnty/app.css")
api.inject_script.assert_called_with("/plugins/svrnty/app.js")
api.inject_stylesheet.assert_any_call("/plugins/svrnty/app.css")
api.inject_script.assert_any_call("/plugins/svrnty/app.js")

View File

@ -0,0 +1,71 @@
"""Unit tests for the Cortex-OS umbrella graph plugin route."""
from __future__ import annotations
import json
import sys
from types import SimpleNamespace
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from routes import umbrella
class _Handler:
def __init__(self):
self.status = None
self.headers = {}
self.body = b""
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.headers[key] = value
def end_headers(self):
pass
@property
def wfile(self):
handler = self
class _W:
def write(self, body):
handler.body += body
return _W()
def test_umbrella_registers_graph_routes():
calls = []
class _Api:
def logger(self, _name):
return SimpleNamespace(info=lambda *args, **kwargs: None)
def register_route(self, path, method, handler):
calls.append((method, path, handler))
umbrella.register(_Api())
assert ("GET", "/api/umbrella", umbrella._handle_graph_json) in calls
assert ("GET", "/api/umbrella/doc", umbrella._handle_doc_body) in calls
def test_umbrella_doc_blocks_path_traversal():
handler = _Handler()
parsed = SimpleNamespace(query="path=../README.md")
assert umbrella._handle_doc_body(handler, parsed) is True
assert handler.status == 403
assert json.loads(handler.body)["error"] == "path outside workspace"
def test_umbrella_doc_serves_sot_readme():
handler = _Handler()
parsed = SimpleNamespace(query="path=sot/README.md")
assert umbrella._handle_doc_body(handler, parsed) is True
assert handler.status == 200
payload = json.loads(handler.body)
assert payload["path"] == "sot/README.md"
assert "Source of Truth" in payload["body"]