340 lines
15 KiB
Python
Executable File
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())
|