Files
Thomas Faour f1fda9bdee Move make-widget/run-server skills to root .claude/skills/
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.
2026-07-25 12:09:39 +00:00

11 KiB

name, description
name description
run-server Build, run, and drive the espresso_frame FastAPI server (server/) -- start it against a scratch DB, browser-test its UI, run its pytest suite. Use when asked to start the server, take a screenshot of a frame page, click through the web UI, or verify a server/UI change actually works.

The espresso_frame server is a FastAPI app (app.main:app) with a server-rendered Jinja UI. For agent/automated use it's driven by a Playwright REPL at .claude/skills/run-server/driver.py, run under tmux -- chromium-cli isn't available in this container, so this driver replaces it (same command vocabulary: nav/wait-for/click/fill/ screenshot/eval/console-errors).

This container ships with no Python, Node, Docker, or browser, and no sudo. setup.sh bootstraps everything non-root; it's the bulk of what makes this skill non-obvious. Run it once per fresh container.

Commands below (setup.sh, start-server.sh, stop-server.sh, env.sh, driver.py) are invoked from the repo root via their .claude/skills/run-server/ path -- the scripts cd into server/ themselves. Anything under .venv/ (the venv itself, pytest, uvicorn) lives inside server/, so those commands need server/ prefixed or cd server first.

Prerequisites

None to install manually -- setup.sh does it all without root, using only curl/apt-get download/dpkg-deb -x (never apt-get install, which needs root). It downloads ~450MB total (Python, Chromium, tmux, shared libs) on first run.

bash .claude/skills/run-server/setup.sh

Re-running is safe and fast -- every step checks whether it already happened (venv exists? chromium downloaded? libs extracted? tmux present?) before doing any work.

This creates:

  • .venv/ -- Python 3.12 + requirements.txt + playwright, via uv (a static Rust binary that fetches its own Python -- no compiler needed, install via curl -LsSf https://astral.sh/uv/install.sh | sh)
  • ~/.cache/ms-playwright/ -- Chromium (full chrome + headless-shell)
  • /tmp/run-server-chromium-deps/ -- Chromium's + tmux's shared libs and fonts, extracted (not installed) from .deb files
  • .claude/skills/run-server/env.sh -- the PATH/LD_LIBRARY_PATH/ FONTCONFIG_PATH/RUN_SERVER_CHROME_BIN exports the driver needs (gitignored -- host-specific /tmp paths, regenerated by setup.sh)

Run (agent path)

1. Start the server against a scratch DB (never the real deployment's data -- see CLAUDE.md):

bash .claude/skills/run-server/start-server.sh
# -> server PID <pid> up on http://127.0.0.1:8420 (log: /tmp/run-server-scratch/server.log)

Optional args: start-server.sh [scratch-dir] [port] (defaults /tmp/run-server-scratch / 8420).

2. Drive it, wrapped in tmux so you can send one command at a time and read the response before sending the next. tmux itself was extracted the same non-root way as Chromium's libs (see Prerequisites) and needs LD_LIBRARY_PATH set in this shell too, not just inside the pane -- source env.sh before the first tmux call:

source .claude/skills/run-server/env.sh
tmux new-session -d -s runserver -x 200 -y 50
tmux send-keys -t runserver \
  'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'

# Every fresh scratch DB starts with no users -- bootstrap-admin
# completes first-run /setup and logs in (see Commands table):
tmux send-keys -t runserver 'bootstrap-admin' Enter
timeout 15 bash -c 'until tmux capture-pane -t runserver -p | grep -q "bootstrapped admin"; do sleep 0.3; done'

tmux send-keys -t runserver 'nav /frames/1/config' Enter
tmux send-keys -t runserver 'wait-for #frame-preview-thumb' Enter
tmux send-keys -t runserver 'screenshot before' Enter
tmux capture-pane -t runserver -p

Poll for a specific marker between send-keys and capture-pane (driver>, bootstrapped admin, screenshot:, ...) rather than a fixed sleep -- it's faster and fails loudly instead of capturing a half-rendered screen. Give each poll its own timeout (~15-20s); don't chain many polling loops inside one shell invocation -- see Gotchas.

Screenshots land in /tmp/run-server-shots/ (override: SCREENSHOT_DIR). Actually look at them -- a blank or error-page screenshot is a failure to launch, not success.

Test every UI change at both a desktop and a mobile viewport. The driver defaults to a desktop size (1280x900); switch with viewport. The app's mobile breakpoint is 860px (theme.css) -- below that the sidebar goes off-canvas behind a hamburger (.mobile-bar). A page that looks right at 1280px can still overflow, overlap the mobile bar, or mis-center a <dialog> at phone widths -- screenshot both:

tmux send-keys -t runserver 'viewport 1280 900' Enter   # desktop (also the default)
tmux send-keys -t runserver 'screenshot desktop-x' Enter
tmux send-keys -t runserver 'viewport 390 844' Enter     # iPhone-ish mobile width
tmux send-keys -t runserver 'screenshot mobile-x' Enter

Driver commands

command what it does
nav <path-or-url> navigate (relative paths resolve against http://127.0.0.1:8420, override with RUN_SERVER_BASE_URL)
wait-for <selector> wait up to 10s for a selector (plain CSS -- see Gotchas for attribute selectors)
click <selector> click an element
fill <selector> <value> fill an input
press <key> keyboard press (e.g. Enter)
screenshot [name] /tmp/run-server-shots/<name>.png
eval <js> evaluate JS in the page, prints JSON
console-errors prints all captured console/page errors as a JSON array
viewport [w] [h] resize the viewport, default 390 844 -- use 1280 900 for desktop (see Gotchas re: real touch input)
is-open <dialog-selector> prints true/false for a <dialog> element's .open -- use this instead of wait-for sel[open]
bootstrap-admin [user] [pass] completes first-run /setup (defaults admin/testpassword123); links frame #1 and logs in
quit closes the browser, exits the driver

3. Stop the server when done:

bash .claude/skills/run-server/stop-server.sh
tmux kill-session -t runserver

Run (human path)

cd server
DATABASE_URL="sqlite:////tmp/dev.db" CONFIG_PATH="/tmp/dev-config.json" \
  .venv/bin/uvicorn app.main:app --reload --port 8420

Open http://localhost:8420 in a real browser. start.sh (the Docker entrypoint) is not this -- it also launches the Node whiteboard render-service sidecar, which needs Node (not installed here) and isn't needed for most UI testing.

Test

cd server && .venv/bin/pytest

Uses its own tempfile SQLite per run (tests/conftest.py) -- no setup needed beyond the venv.


Gotchas

  • viewport only resizes the window -- it does not emulate touch input. click still dispatches a mouse click, not a tap; there's no touch-delay, no :hover-stickiness-after-tap, no hasTouch context. It catches real bugs (layout overflow, off-canvas sidebar, a <dialog> mis-centering at phone widths) but won't catch anything that's specifically a touch-vs-mouse event difference. Good enough for CSS/layout verification; not a substitute for testing on an actual phone if a change touches touch-specific interaction.

  • chrome-headless-shell (Playwright's default headless target) crashes on basic calls in this container, e.g. page.set_content() returns TargetClosedError, even after every ldd-reported missing library is resolved. The full chrome binary (chromium-*/chrome-linux64/chrome)

    • --no-sandbox is stable; driver.py and setup.sh both use it, not the headless-shell default.
  • Missing fonts silently break fill(), not just rendering. Before fontconfig/libfontconfig1 were extracted and fonts.conf pointed at the extracted font dir, page.fill() ran with no error but left inputs empty (input_value() returned ""), and all text rendered invisible in screenshots. It looks like a scripting bug, not a missing-lib problem -- if fill silently no-ops, suspect fonts before suspecting the selector or a race.

  • No apt-get install / playwright install-deps (no root) and no apt-get update into the real /var/lib/apt/lists (root-owned). Worked around by redirecting apt's state dirs to a scratch, user-writable path (-o Dir::State::Lists=... -o Dir::Cache=...), which makes plain update and install --download-only --print-uris work as a non-root user; then dpkg-deb -x <deb> <root> (extract, not install) needs no root either. setup.sh does this for Chromium's deps and for tmux itself, which also isn't preinstalled.

  • wait-for with an attribute selector like #frame-preview-dialog[open] is unreliable through tmux send-keys -- shell/tmux escaping of [/] easily mangles it (seen: a real 10s Playwright timeout from a garbled selector, not a fast failure). Use the app-specific is-open <selector> command instead of wait-for sel[open] to check a <dialog>'s open state.

  • Don't chain many tmux send-keys + polling-timeout loops inside one shell invocation. Each poll can legitimately take up to its own timeout (e.g. 15s) if a selector is wrong; five or six chained in one command can add up past this tool's own command timeout even though each individual step is fine. Send one or two commands per shell call and check the pane before continuing.

  • A crashed chrome-headless-shell process can leave a large core dump file (server/core, ~170MB, from the crash described above) if core dumps are enabled. It's not part of the app -- delete it, and use full chrome (as driver.py does) to avoid triggering it again.

Troubleshooting

  • error while loading shared libraries: libglib-2.0.so.0 (or similar) when launching Chromium directly: LD_LIBRARY_PATH isn't set -- source .claude/skills/run-server/env.sh first, or run through driver.py, which reads RUN_SERVER_CHROME_BIN from it.
  • fc-list prints nothing after extracting fonts: fonts.conf's <dir> entries still point at the real (unpopulated) /usr/share/fonts. setup.sh patches this with sed; if you extracted packages by hand, do the same.
  • E: Could not open lock file ... Permission denied from apt-get: you're missing the -o Dir::State::Lists=... -o Dir::Cache=... overrides -- plain apt-get update/install always needs root here.
  • tmux: command not found: not preinstalled and no sudo; run setup.sh, which fetches it the same non-root way as Chromium's libs.
  • tmux: error while loading shared libraries: libutempter.so.0: you sourced env.sh inside the driver's tmux pane but not in the shell that invokes tmux itself -- tmux was extracted from the same non-root .deb set as Chromium and needs LD_LIBRARY_PATH too. source .claude/skills/run-server/env.sh before the first tmux command, not just inside send-keys.