Validate umbrella graph WebUI proof
This commit is contained in:
parent
b75fbf48ae
commit
4ad595506a
@ -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)
|
||||
|
||||
188
scripts/umbrella-visual-smoke.py
Normal file
188
scripts/umbrella-visual-smoke.py
Normal 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())
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
71
tests/unit/test_umbrella.py
Normal file
71
tests/unit/test_umbrella.py
Normal 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"]
|
||||
Loading…
Reference in New Issue
Block a user