← 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()