Nested .claude/skills/ dirs (previously under server/) are only auto-discovered on-demand once a file under that subdirectory is touched, so /make-widget and /run-server weren't invocable from a fresh session. Root .claude/skills/ is scanned at session start. Fixes setup.sh/start-server.sh's relative cd-depth math (was hardcoded for the old server/.claude/skills/run-server/ depth) to instead resolve the repo root via git and cd into server/ explicitly, and updates SKILL.md/driver.py's path references to match the new layout.
178 lines
5.7 KiB
Python
178 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""REPL driver for the espresso_frame server's web UI.
|
|
|
|
Playwright-based since chromium-cli isn't available in this container.
|
|
Reads one command per line from stdin, prints a result line -- built
|
|
for tmux send-keys/capture-pane use by an agent. Vocabulary mirrors
|
|
chromium-cli where it overlaps (nav/wait-for/click/fill/screenshot/
|
|
eval/console-errors).
|
|
|
|
Requires setup.sh to have run first (Python venv + Playwright Chromium
|
|
+ the non-root shared-lib/font extraction). Run via:
|
|
|
|
.claude/skills/run-server/env.sh sourced, then
|
|
server/.venv/bin/python .claude/skills/run-server/driver.py
|
|
|
|
See SKILL.md for the full agent-path invocation (tmux wrapping etc).
|
|
"""
|
|
import glob
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
SHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/tmp/run-server-shots")
|
|
os.makedirs(SHOT_DIR, exist_ok=True)
|
|
BASE = os.environ.get("RUN_SERVER_BASE_URL", "http://127.0.0.1:8420")
|
|
|
|
|
|
def find_chrome() -> str:
|
|
override = os.environ.get("RUN_SERVER_CHROME_BIN")
|
|
if override and os.path.exists(override):
|
|
return override
|
|
matches = glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome"))
|
|
if not matches:
|
|
sys.exit("chrome binary not found -- run setup.sh first")
|
|
return matches[0]
|
|
|
|
|
|
pw = sync_playwright().start()
|
|
# The FULL `chrome` binary, not chrome-headless-shell (Playwright's
|
|
# default headless target): chrome-headless-shell crashed on basic
|
|
# calls like set_content() in this container even once every
|
|
# ldd-reported missing lib was resolved. Full chrome + --no-sandbox is
|
|
# stable here.
|
|
browser = pw.chromium.launch(executable_path=find_chrome(), args=["--no-sandbox"])
|
|
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
|
console_errors: list[str] = []
|
|
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
|
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
|
# Playwright auto-DISMISSES native confirm()/alert() dialogs by default
|
|
# (returns false) -- several destructive actions in this app (remove
|
|
# widget, clear all widgets) gate on `confirm()`, so without this a
|
|
# `click` on one of those buttons would silently no-op. Auto-accept
|
|
# instead, since a driver testing a "yes, do the destructive thing"
|
|
# flow needs the confirm to actually go through.
|
|
page.on("dialog", lambda dialog: dialog.accept())
|
|
|
|
|
|
def cmd_nav(arg):
|
|
url = arg if arg.startswith("http") else BASE + arg
|
|
page.goto(url)
|
|
print("nav ->", page.url)
|
|
|
|
|
|
def cmd_wait_for(arg):
|
|
page.wait_for_selector(arg, timeout=10000)
|
|
print("found:", arg)
|
|
|
|
|
|
def cmd_click(arg):
|
|
page.click(arg)
|
|
print("clicked:", arg)
|
|
|
|
|
|
def cmd_fill(arg):
|
|
sel, _, value = arg.partition(" ")
|
|
page.fill(sel, value)
|
|
print("filled:", sel, "=", value)
|
|
|
|
|
|
def cmd_press(arg):
|
|
page.keyboard.press(arg)
|
|
print("pressed:", arg)
|
|
|
|
|
|
def cmd_screenshot(arg):
|
|
name = arg or f"ss-{len(os.listdir(SHOT_DIR))}"
|
|
path = os.path.join(SHOT_DIR, name + ".png")
|
|
page.screenshot(path=path)
|
|
print("screenshot:", path)
|
|
|
|
|
|
def cmd_eval(arg):
|
|
try:
|
|
print(json.dumps(page.evaluate(arg)))
|
|
except Exception as e:
|
|
print("ERROR:", e)
|
|
|
|
|
|
def cmd_console_errors(_arg):
|
|
print(json.dumps(console_errors))
|
|
|
|
|
|
def cmd_viewport(arg):
|
|
"""Resize the viewport. No args -> 390x844 (iPhone-ish mobile
|
|
width); the page itself starts at 1280x900 (desktop) on launch, so
|
|
`viewport 1280 900` gets back to that. The app's mobile breakpoint
|
|
is 860px (see theme.css) -- anything under that exercises the
|
|
off-canvas sidebar/mobile-bar layout."""
|
|
parts = arg.split()
|
|
width = int(parts[0]) if len(parts) > 0 else 390
|
|
height = int(parts[1]) if len(parts) > 1 else 844
|
|
page.set_viewport_size({"width": width, "height": height})
|
|
print("viewport:", width, "x", height)
|
|
|
|
|
|
def cmd_is_open(arg):
|
|
"""App-specific: print whether a <dialog> element is open (true/false)."""
|
|
print(json.dumps(page.eval_on_selector(arg, "el => el.open")))
|
|
|
|
|
|
def cmd_bootstrap_admin(arg):
|
|
"""App-specific: complete first-run /setup (username/password args,
|
|
default admin/testpassword123). Every scratch DB starts with no
|
|
users, and /setup is the only way in -- it also auto-links the
|
|
pre-existing frame #1 (created by migrations) to the new admin, so
|
|
/frames/1/... is reachable right after this."""
|
|
parts = arg.split()
|
|
username = parts[0] if len(parts) > 0 else "admin"
|
|
password = parts[1] if len(parts) > 1 else "testpassword123"
|
|
page.goto(BASE + "/setup")
|
|
page.fill("input[name=username]", username)
|
|
page.fill("input[name=password]", password)
|
|
page.click("button[type=submit]")
|
|
page.wait_for_load_state("networkidle")
|
|
print("bootstrapped admin, now at:", page.url)
|
|
|
|
|
|
def cmd_quit(_arg):
|
|
browser.close()
|
|
pw.stop()
|
|
sys.exit(0)
|
|
|
|
|
|
COMMANDS = {
|
|
"nav": cmd_nav,
|
|
"wait-for": cmd_wait_for,
|
|
"click": cmd_click,
|
|
"fill": cmd_fill,
|
|
"press": cmd_press,
|
|
"screenshot": cmd_screenshot,
|
|
"eval": cmd_eval,
|
|
"console-errors": cmd_console_errors,
|
|
"viewport": cmd_viewport,
|
|
"is-open": cmd_is_open,
|
|
"bootstrap-admin": cmd_bootstrap_admin,
|
|
"quit": cmd_quit,
|
|
}
|
|
|
|
print("run-server driver -- commands:", ", ".join(COMMANDS), flush=True)
|
|
print("driver>", end=" ", flush=True)
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
print("driver>", end=" ", flush=True)
|
|
continue
|
|
cmd, _, rest = line.partition(" ")
|
|
fn = COMMANDS.get(cmd)
|
|
if fn is None:
|
|
print("unknown command:", cmd, "-- try one of:", ", ".join(COMMANDS))
|
|
else:
|
|
try:
|
|
fn(rest)
|
|
except Exception as e:
|
|
print("ERROR:", e)
|
|
print("driver>", end=" ", flush=True)
|