From 4ad595506acaa093ae34d8b094a6adafd535f5b2 Mon Sep 17 00:00:00 2001 From: Svrnty Date: Mon, 25 May 2026 12:57:39 -0400 Subject: [PATCH] Validate umbrella graph WebUI proof --- scripts/boot-smoke.py | 25 ++- scripts/umbrella-visual-smoke.py | 188 ++++++++++++++++++++++ static/umbrella.js | 10 ++ tests/integration/test_loader_contract.py | 6 +- tests/unit/test_brand_skin.py | 4 +- tests/unit/test_umbrella.py | 71 ++++++++ 6 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 scripts/umbrella-visual-smoke.py create mode 100644 tests/unit/test_umbrella.py diff --git a/scripts/boot-smoke.py b/scripts/boot-smoke.py index bc4ea6b..eaa14d4 100755 --- a/scripts/boot-smoke.py +++ b/scripts/boot-smoke.py @@ -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) diff --git a/scripts/umbrella-visual-smoke.py b/scripts/umbrella-visual-smoke.py new file mode 100644 index 0000000..1ca4b1c --- /dev/null +++ b/scripts/umbrella-visual-smoke.py @@ -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()) diff --git a/static/umbrella.js b/static/umbrella.js index 0d41bbc..b470bdf 100644 --- a/static/umbrella.js +++ b/static/umbrella.js @@ -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); } } diff --git a/tests/integration/test_loader_contract.py b/tests/integration/test_loader_contract.py index d8294ef..649ea86 100644 --- a/tests/integration/test_loader_contract.py +++ b/tests/integration/test_loader_contract.py @@ -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 diff --git a/tests/unit/test_brand_skin.py b/tests/unit/test_brand_skin.py index ab4bf30..1f77c5b 100644 --- a/tests/unit/test_brand_skin.py +++ b/tests/unit/test_brand_skin.py @@ -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") diff --git a/tests/unit/test_umbrella.py b/tests/unit/test_umbrella.py new file mode 100644 index 0000000..c201b7f --- /dev/null +++ b/tests/unit/test_umbrella.py @@ -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"]