← back to Agent Cabinet
scripts/smoke-pyramid-ui.py
157 lines
#!/usr/bin/env python3
"""Persistent UI smoke test for the Agent Cabinet pyramid viewer.
Drives a real headless Chromium against the running server and ASSERTS that each
hardened control actually does its job (DOM re-renders, drawer focus, toggle
visibility, persistence) — not merely that the page loads.
Usage: python3 scripts/smoke-pyramid-ui.py [BASE_URL]
BASE_URL defaults to http://127.0.0.1:9766
Exits non-zero on the first failed assertion.
"""
import sys
import urllib.request
from playwright.sync_api import sync_playwright, expect
BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9766").rstrip("/")
PY = BASE + "/pyramid"
passed = []
failed = []
def ok(name):
passed.append(name)
print(f" PASS {name}")
def bad(name, detail=""):
failed.append(name)
print(f" FAIL {name} {detail}")
def check_redirects():
# P1-0: /pyramid.html and /pyramid/ must 301 -> /pyramid (no auto-follow).
for path in ("/pyramid.html", "/pyramid/"):
req = urllib.request.Request(BASE + path, method="GET")
opener = urllib.request.build_opener(NoRedirect())
try:
opener.open(req)
bad(f"301 {path}", "expected redirect, got 2xx")
except urllib.error.HTTPError as e:
if e.code == 301 and e.headers.get("Location") == "/pyramid":
ok(f"301 {path} -> /pyramid")
else:
bad(f"301 {path}", f"code={e.code} loc={e.headers.get('Location')}")
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
def run_browser():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
page = ctx.new_page()
page.goto(PY)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(400) # let the double-rAF redraw settle
# ---- P1: overlay default state (calmer) ----
# Hierarchy + Gaps default ON; Shared + Suggested default OFF.
assert page.locator("#tg-hier").is_checked(), "hierarchy should default on"
ok("default tg-hier ON")
assert page.locator("#tg-gap").is_checked(), "gaps should default on"
ok("default tg-gap ON")
assert not page.locator("#tg-shared").is_checked(), "shared should default off"
ok("default tg-shared OFF")
assert not page.locator("#tg-sug").is_checked(), "suggested should default off"
ok("default tg-sug OFF")
# The suggested overlay group must actually be hidden when its toggle is off.
gsug = page.locator("#g-suggest")
if gsug.count():
disp = gsug.evaluate("e => getComputedStyle(e).display")
assert disp == "none", f"g-suggest display={disp}, expected none"
ok("g-suggest hidden by default")
# Gap lines visible by default.
ggaps = page.locator("#g-gaps")
if ggaps.count():
disp = ggaps.evaluate("e => getComputedStyle(e).display")
assert disp != "none", "g-gaps should be visible by default"
ok("g-gaps visible by default")
# Toggling Suggested ON must reveal the group (DOM re-render assertion).
page.locator("#tg-sug").check()
page.wait_for_timeout(150)
disp = page.locator("#g-suggest").evaluate("e => getComputedStyle(e).display")
assert disp != "none", "g-suggest should show after toggle on"
ok("toggle tg-sug ON reveals g-suggest")
# ---- P1: persistence across reload ----
page.reload()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(300)
assert page.locator("#tg-sug").is_checked(), "tg-sug should persist ON after reload"
ok("tg-sug persists ON after reload")
# restore default for a clean state
page.locator("#tg-sug").uncheck()
# ---- P1: gap handle size + actionable ghost node ----
gh = page.locator(".gap-h").first
gh.wait_for(state="attached", timeout=5000)
box = gh.bounding_box()
assert box and box["width"] >= 27, f"gap handle too small: {box['width'] if box else None}px"
ok(f"gap handle >=28px ({round(box['width'])}px)")
# ---- P1: drawer focus management on gap open ----
gh.scroll_into_view_if_needed()
gh.click()
page.wait_for_timeout(350)
assert "open" in (page.locator("#drawer").get_attribute("class") or ""), "drawer should open"
ok("gap handle opens drawer")
focused = page.evaluate("document.activeElement && document.activeElement.id")
assert focused in ("dr-run", "dr-open"), f"focus on primary action expected, got {focused}"
ok(f"drawer focuses primary action ({focused})")
# ---- P1: Approve dominant / Dismiss secondary + renamed ----
dismiss_txt = page.locator("#dr-open").inner_text()
assert "Dismiss" in dismiss_txt, f"dismiss button should say Dismiss, got '{dismiss_txt}'"
ok(f"dismiss renamed ('{dismiss_txt.strip()}')")
# ---- P0 regression: Escape closes + focus restore ----
page.keyboard.press("Escape")
page.wait_for_timeout(250)
assert "open" not in (page.locator("#drawer").get_attribute("class") or ""), "Escape should close drawer"
ok("Escape closes drawer")
# ---- P2: zoom-to-fit control exists & redraws ----
if page.locator("#zoom-fit").count():
page.locator("#zoom-fit").click()
page.wait_for_timeout(200)
ok("zoom-fit control present & clickable")
# ---- P2: gaps-state-unavailable indicator wiring exists ----
if page.locator("#state-warn").count():
ok("state-warn indicator element present")
browser.close()
def main():
print(f"Smoke test: {PY}")
check_redirects()
try:
run_browser()
except AssertionError as e:
bad("browser-assertion", str(e))
except Exception as e:
bad("browser-exception", str(e))
print(f"\n{len(passed)} passed, {len(failed)} failed")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()