This sandbox ships with no Python/Node/Docker/browser and no sudo, so the bulk of this is setup.sh: bootstrap Python via uv, then get Playwright's Chromium (and tmux, also missing) working by extracting their .deb dependencies non-root instead of apt-get install. driver.py is a small Playwright REPL standing in for chromium-cli, which isn't available here either. Also carves out .claude/skills/ from the blanket .claude/ gitignore -- skills are shared project tooling, not personal/local state.
157 lines
4.6 KiB
Python
157 lines
4.6 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:
|
|
|
|
server/.claude/skills/run-server/env.sh sourced, then
|
|
.venv/bin/python server/.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)))
|
|
|
|
|
|
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_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,
|
|
"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)
|