svrnty-hermes-webui-plugin/scripts/canvas-visual-smoke.py
2026-05-28 21:44:02 -04:00

340 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Browser smoke for the Hermes Canvas command-center loop.
Starts canva-editor in mock mode, starts hermes-webui with the Svrnty plugin,
opens the real WebUI shell, switches to Canvas, generates variants, captures a
screenshot, and writes a compact JSON proof.
"""
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"
CANVA_REPO = HERMES_REPO.parent / "cortex" / "L2-svrnty.tool-canva-editor"
CMO_REPO = HERMES_REPO / "cmo"
DEFAULT_OUT = HERMES_REPO / ".scratch" / "canvas-visual-proof.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)
bright = sum(1 for r, g, b in pixels if max(r, g, b) > 150)
distinct = len(set(pixels[:: max(1, total // 20000)]))
return {
"path": str(path),
"width": width,
"height": height,
"bright_ratio": round(bright / total, 4),
"sampled_distinct_colors": distinct,
"nonblank": bright > 1000 and distinct > 20,
}
def _start_canva(port: int, db_path: Path) -> subprocess.Popen:
env = os.environ.copy()
env["PATH"] = f"/home/svrnty/sdk/go1.26.0/bin{os.pathsep}{env.get('PATH', '')}"
env["PORT"] = str(port)
env["DB_PATH"] = str(db_path)
return subprocess.Popen(
["go", "run", "./cmd/server", "--mock"],
cwd=CANVA_REPO,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
preexec_fn=os.setsid,
)
def _start_webui(port: int, state_dir: Path, canva_base: str, plugin_module: str) -> subprocess.Popen:
env = os.environ.copy()
env.pop("HERMES_WEBUI_PASSWORD", None)
env["CANVA_EDITOR_BASE_URL"] = canva_base
env["HERMES_WEBUI_PYTHON_PLUGIN"] = plugin_module
if plugin_module == "svrnty_hermes_webui_refactor_plugin":
env["SVRNTY_WEBUI_DEVELOPMENT_INCLUDE_PROD"] = "1"
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}{HERMES_REPO / 'webui-plugin-development'}{os.pathsep}{AGENT_REPO}"
if not env.get("PYTHONPATH")
else f"{PLUGIN_REPO}{os.pathsep}{HERMES_REPO / 'webui-plugin-development'}{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"
return subprocess.Popen(
[python_exe, str(WEBUI_REPO / "server.py")],
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 _stop(proc: subprocess.Popen | None) -> str:
if proc is None:
return ""
output = ""
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 proc.stdout:
try:
output = proc.stdout.read(4000)
except Exception:
output = ""
return output
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output-json", default=str(DEFAULT_OUT))
parser.add_argument("--screenshot-dir", default=str(HERMES_REPO / ".scratch" / "screenshots" / "canvas"))
parser.add_argument("--plugin-module", default="svrnty_hermes_webui_plugin")
args = parser.parse_args()
if not CANVA_REPO.exists():
raise SystemExit(f"missing canva-editor repo: {CANVA_REPO}")
out_json = Path(args.output_json)
screenshot_dir = Path(args.screenshot_dir)
screenshot_dir.mkdir(parents=True, exist_ok=True)
canva_port = _free_port()
webui_port = _free_port()
canva_base = f"http://127.0.0.1:{canva_port}"
webui_base = f"http://127.0.0.1:{webui_port}"
canva_proc: subprocess.Popen | None = None
webui_proc: subprocess.Popen | None = None
with tempfile.TemporaryDirectory(prefix="hermes-canvas-webui-") as tmp:
tmp_path = Path(tmp)
canva_proc = _start_canva(canva_port, tmp_path / "canva-editor.db")
try:
if not _wait_for(f"{canva_base}/health", timeout=45):
raise RuntimeError(f"canva-editor did not become healthy at {canva_base}/health")
webui_proc = _start_webui(webui_port, tmp_path / "webui-state", canva_base, args.plugin_module)
if not _wait_for(f"{webui_base}/health", timeout=45):
raise RuntimeError(f"webui did not become healthy at {webui_base}/health")
proof = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"webui_base": webui_base,
"canva_base": canva_base,
"plugin_module": args.plugin_module,
}
cmo_helper = subprocess.run(
[
sys.executable,
str(CMO_REPO / "canvas_command.py"),
"command",
"Create a CMO-triggered draft promo card for Plan B",
"--variants",
"1",
"--brand",
"planb",
"--project",
"Hermes CMO Canvas Smoke",
"--wait",
],
env={**os.environ, "HERMES_WEBUI_URL": webui_base},
capture_output=True,
text=True,
timeout=60,
)
try:
cmo_helper_payload = json.loads(cmo_helper.stdout or "{}")
except ValueError:
cmo_helper_payload = {"parse_error": cmo_helper.stdout}
proof["cmo_helper"] = {
"returncode": cmo_helper.returncode,
"ok": cmo_helper.returncode == 0 and bool(cmo_helper_payload.get("ok")),
"command_id": cmo_helper_payload.get("command_id"),
"screens": len(cmo_helper_payload.get("screens") or []),
"event_wait_ok": bool((cmo_helper_payload.get("event_wait") or {}).get("ok")),
"design_context_status": (cmo_helper_payload.get("design_context") or {}).get("status"),
"secondbrain_status": (cmo_helper_payload.get("design_context") or {}).get("secondbrain_status"),
"stderr": cmo_helper.stderr[-1000:],
}
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"],
)
page = browser.new_page(viewport={"width": 1440, "height": 1000})
page.goto(webui_base, wait_until="domcontentloaded", timeout=30000)
page.wait_for_function(
"() => window.SvrntyCanvas && window.SvrntyNav && typeof window.switchPanel === 'function'",
timeout=30000,
)
slash_registered = page.evaluate(
"() => typeof COMMANDS !== 'undefined' && COMMANDS.some((cmd) => cmd && cmd.name === 'canvas')"
)
page.evaluate("() => window.switchPanel('canvas')")
page.wait_for_selector("#svrntyCanvasOverlay:not([hidden])", timeout=10000)
page.wait_for_function(
"() => document.querySelector('#svrntyCanvasStatus')?.textContent.includes('online')",
timeout=30000,
)
page.evaluate(
"""() => window.SvrntyCanvas.request({
prompt: 'Create a polished CMO launch dashboard with campaign KPIs',
variants: 2,
brand_id: 'planb',
source: 'visual-smoke'
})"""
)
page.wait_for_function(
"() => document.querySelectorAll('.svrnty-canvas-artboard').length >= 2",
timeout=60000,
)
editable = page.locator('.svrnty-canvas-artboard:first-child .svrnty-canvas-node[contenteditable="true"]').first
editable.fill("Edited KPI panel")
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="save"]')
try:
page.wait_for_function(
"() => !!window.SvrntyCanvas.state.lastEditPersisted",
timeout=30000,
)
except Exception:
debug_state = page.evaluate(
"""() => ({
artboards: window.SvrntyCanvas.state.artboards.map((b) => ({id:b.id,title:b.title,dirty:b.dirty,badge:b.badge})),
logs: window.SvrntyCanvas.state.log.map((row) => row.message),
editableCount: document.querySelectorAll('.svrnty-canvas-node[contenteditable="true"]').length,
saveButtonCount: document.querySelectorAll('[data-canvas-action="save"]').length
})"""
)
print(json.dumps({"canvas_edit_debug": debug_state}, indent=2), file=sys.stderr)
raise
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="prototype-source"]')
page.click('.svrnty-canvas-artboard:nth-child(2) [data-canvas-action="prototype-link"]')
page.wait_for_function(
"() => !!window.SvrntyCanvas.state.lastPrototypeEdge",
timeout=30000,
)
page.click('.svrnty-canvas-artboard:first-child [data-canvas-action="export"]')
page.wait_for_function(
"() => window.SvrntyCanvas.state.exportSummary && window.SvrntyCanvas.state.exportSummary.screens.length >= 2",
timeout=30000,
)
state = page.evaluate(
"""(slashRegistered) => ({
connected: window.SvrntyCanvas.state.connected,
artboards: window.SvrntyCanvas.state.artboards.length,
slashCommandRegistered: slashRegistered,
editPersisted: !!window.SvrntyCanvas.state.lastEditPersisted,
prototypeLinked: !!window.SvrntyCanvas.state.lastPrototypeEdge,
exportScreens: window.SvrntyCanvas.state.exportSummary ? window.SvrntyCanvas.state.exportSummary.screens.length : 0,
exportPrototypeEdges: window.SvrntyCanvas.state.exportSummary ? window.SvrntyCanvas.state.exportSummary.prototypeEdges.length : 0,
designContextReady: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.status === 'ready' : false,
designContextSource: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.source : '',
designContextVersion: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.source_version : '',
secondbrainStatus: window.SvrntyCanvas.state.designContext ? window.SvrntyCanvas.state.designContext.secondbrain_status : '',
memoryHintCount: window.SvrntyCanvas.state.designContext && window.SvrntyCanvas.state.designContext.memory_hints ? window.SvrntyCanvas.state.designContext.memory_hints.length : 0,
logs: window.SvrntyCanvas.state.log.map((row) => row.message).slice(0, 8),
status: document.querySelector('#svrntyCanvasStatus')?.textContent || '',
mainClass: document.querySelector('main.main')?.className || ''
})""",
slash_registered,
)
shot = screenshot_dir / "canvas-desktop-proof.png"
page.screenshot(path=str(shot), full_page=True)
browser.close()
proof["state"] = state
proof["pixel_stats"] = _pixel_stats(shot)
proof["pass"] = bool(
state["connected"]
and state["artboards"] >= 2
and state["slashCommandRegistered"]
and state["editPersisted"]
and state["prototypeLinked"]
and state["exportScreens"] >= 2
and state["exportPrototypeEdges"] >= 1
and state["designContextReady"]
and state["designContextSource"] == "cmo.brand_profile_cache"
and state["secondbrainStatus"] in ("ready", "unconfigured")
and state["memoryHintCount"] >= 1
and proof["cmo_helper"]["ok"]
and proof["cmo_helper"]["screens"] >= 1
and proof["cmo_helper"]["event_wait_ok"]
and proof["cmo_helper"]["design_context_status"] == "ready"
and "svrnty-showing-canvas" in state["mainClass"]
and proof["pixel_stats"]["nonblank"]
)
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:
webui_log = _stop(webui_proc)
canva_log = _stop(canva_proc)
if webui_log:
print(webui_log, file=sys.stderr)
if canva_log:
print(canva_log, file=sys.stderr)
if __name__ == "__main__":
raise SystemExit(main())