master
John Lauer v1.9.49: design-system pass (house header, line-drawing icon + favicon, newbie panel, status pills, settings modal, toasts, tokens, fonts, no dashes) and Hydrogen dock membership (dockbar.json, adom-tts open --print-url, /api/health) 180206b 1mo ago
#!/usr/bin/env python3
"""UI ralph harness for the player page: drives a MUTED headless Chrome over raw
CDP (no puppeteer needed) against an isolated rig drainer and writes one PNG per
state. Never touches the human's player or any Hydrogen tab.

  python3 tools/ui-shot.py http://127.0.0.1:<rig-port>/ <outdir>

States captured: idle, clip (transcript + history + freshness pill), settings
modal, toasts, stale-audio pill, pill detail popover, corner tooltip, newbie
panel closed, and phone width (open and closed). Also prints the page's boot
marker and the newbie module's selfCheck so a half-booted page cannot pass.
"""
import asyncio, json, os, glob, socket, subprocess, sys, time, urllib.request
import websockets

URL, OUT = sys.argv[1], sys.argv[2]
os.makedirs(OUT, exist_ok=True)

def find_shell():
    cands = glob.glob(os.path.expanduser("~/.cache/puppeteer/chrome-headless-shell/*/chrome-headless-shell-linux64/chrome-headless-shell")) \
          + glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium_headless_shell-*/chrome-linux/headless_shell"))
    for c in ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome"]:
        if os.path.exists(c): cands.append(c)
    if not cands: sys.exit("ERROR: no headless chrome found. Hint: npx puppeteer browsers install chrome-headless-shell")
    return cands[0]

def free_port():
    s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p

port = free_port()
shell = find_shell()
proc = subprocess.Popen([shell, "--headless", "--no-sandbox", "--disable-gpu", "--mute-audio", "--hide-scrollbars",
    "--autoplay-policy=no-user-gesture-required", f"--remote-debugging-port={port}", "--window-size=1400,900", "about:blank"],
    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

def targets():
    for _ in range(50):
        try:
            with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=1) as r: return json.load(r)
        except Exception: time.sleep(0.2)
    sys.exit("ERROR: headless chrome never answered on its debug port")

async def main():
    page = next(t for t in targets() if t["type"] == "page")
    async with websockets.connect(page["webSocketDebuggerUrl"], max_size=64 * 1024 * 1024) as ws:
        seq = 0
        async def call(method, **params):
            nonlocal seq; seq += 1
            await ws.send(json.dumps({"id": seq, "method": method, "params": params}))
            while True:
                m = json.loads(await ws.recv())
                if m.get("id") == seq:
                    if "error" in m: raise RuntimeError(f"{method}: {m['error']}")
                    return m.get("result", {})
        async def js(expr):
            r = await call("Runtime.evaluate", expression=expr, awaitPromise=True, returnByValue=True)
            return r.get("result", {}).get("value")
        async def shot(name):
            r = await call("Page.captureScreenshot", format="png")
            import base64; open(os.path.join(OUT, name + ".png"), "wb").write(base64.b64decode(r["data"]))
            print("shot", name)
        async def size(w, h):
            await call("Emulation.setDeviceMetricsOverride", width=w, height=h, deviceScaleFactor=1, mobile=w < 600)

        await call("Page.enable"); await call("Runtime.enable")
        await size(1400, 900)
        await call("Page.navigate", url=URL); await asyncio.sleep(3.0)
        print("boot:", await js("JSON.stringify(window.__ttsBoot)"))
        print("panel selfCheck:", await js("(function(){try{var p=window.AdomNewbiePanel;if(!p)return 'no module';if(!p.selfCheck)return 'version '+p.version+' (no selfCheck)';var r=p.selfCheck();return JSON.stringify(r).slice(0,600);}catch(e){return 'threw '+e}})()"))
        await shot("01-idle")
        # a clip: the caller enqueued one; wait for the page to swap and render
        for _ in range(30):
            if await js("!!(document.getElementById('transcript') && !document.getElementById('transcript').hidden)"): break
            await asyncio.sleep(0.5)
        await asyncio.sleep(1.5)
        await shot("02-clip")
        print("icons while playing:", await js("JSON.stringify({play:document.getElementById('ic-play').hasAttribute('hidden'),pause:document.getElementById('ic-pause').hasAttribute('hidden'),freshWarnHidden:document.getElementById('freshIcon').hasAttribute('hidden'),status:document.getElementById('status').textContent})"))
        print("panel-open spacing:", await js("(function(){var n=document.getElementById('newbie').firstElementChild;var r=n?n.getBoundingClientRect():null;var h=document.querySelector('header.dash').getBoundingClientRect();return JSON.stringify({panelTop:r&&r.top,panelBottom:r&&r.bottom,headerBottom:h.bottom,ledsTop:document.getElementById('leds').getBoundingClientRect().top})})()"))
        # let the clip end so History renders its rows (replay icon check)
        await js("var a=document.getElementById('a'); if(isFinite(a.duration)) a.currentTime=Math.max(0,a.duration-0.4);"); await asyncio.sleep(3.0)
        await size(1400, 1150); await asyncio.sleep(0.3)   # tall enough to show History under a full transcript
        await shot("02b-ended-history")
        await size(1400, 900)
        await js("document.getElementById('settings-btn').click()"); await asyncio.sleep(0.8)
        await shot("03-settings")
        await js("document.getElementById('settingsDone').click()")
        await js("showToast('Running test 3 of 8', 'info'); showToast('File downloaded', 'success'); showToast('Voice service did not answer. Run adom-tts health.', 'error')")
        await asyncio.sleep(0.4); await shot("04-toasts")
        await js("document.querySelectorAll('.toast .x').forEach(function(b){b.click()})")
        await js("setClipAge(Date.parse('2026-06-16T22:55:00'), Date.now())"); await asyncio.sleep(0.2)
        await shot("05-stale-pill")
        await js("document.querySelector('.led-pill[data-key=service]').click()"); await asyncio.sleep(0.3)
        await shot("06-pill-detail")
        await js("document.body.click()")
        # tooltip clamp test: cursor never moved, so the anchor is (0,0) and must clamp inside the viewport
        await js("__showTooltipFor(document.getElementById('targetChip'))"); await asyncio.sleep(1.0)
        print("tooltip rect:", await js("(function(){var r=document.getElementById('global-tooltip').getBoundingClientRect();return JSON.stringify({l:r.left,t:r.top,r:r.right,b:r.bottom,vw:innerWidth,vh:innerHeight,inside:(r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight)})})()"))
        await shot("07-tooltip")
        await js("document.getElementById('global-tooltip').className=''")
        await js("try{AdomNewbiePanel.close({remember:false})}catch(e){document.getElementById('help-btn').click()}"); await asyncio.sleep(0.5)
        await shot("08-panel-closed")
        print("gutter check (panel open/closed spacing under header):", await js("(function(){var l=document.getElementById('leds').getBoundingClientRect();var h=document.querySelector('header.dash').getBoundingClientRect();return JSON.stringify({ledsTop:l.top,headerBottom:h.bottom,ledsLeft:l.left})})()"))
        await size(390, 844); await asyncio.sleep(0.6)
        await shot("09-phone-closed")
        await js("try{AdomNewbiePanel.open({remember:false})}catch(e){document.getElementById('help-btn').click()}"); await asyncio.sleep(0.5)
        await shot("10-phone-open")
        print("phone overflow:", await js("JSON.stringify({scrollW:document.documentElement.scrollWidth, vw:innerWidth})"))
        await js("try{AdomNewbiePanel.close({remember:false})}catch(e){}")
        await size(2400, 1000); await asyncio.sleep(0.5)
        await shot("11-wide-closed")
        print("console errors:", await js("JSON.stringify((window.__ttsBoot||{}).failed)"))

try:
    asyncio.run(main())
finally:
    proc.terminate()