189 lines
6.8 KiB
Python
189 lines
6.8 KiB
Python
#!/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())
|