Confirmed the theatre-mode preview dialog actually renders correctly at phone widths (390x844) -- centers properly, backdrop and close button both fine, nothing overflows. Added a `viewport` command to the driver (defaults to mobile, since that's the step easy to skip) and a CLAUDE.md rule to screenshot both breakpoints for future UI changes, since the 860px sidebar/mobile-bar fork is a real, previously-hit source of bugs here.
230 lines
11 KiB
Markdown
230 lines
11 KiB
Markdown
---
|
|
name: run-server
|
|
description: 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.
|
|
|
|
All paths below are relative to `server/`.
|
|
|
|
## 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
|
|
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
|
|
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:
|
|
|
|
```bash
|
|
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 && .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:
|
|
|
|
```bash
|
|
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
|
|
bash .claude/skills/run-server/stop-server.sh
|
|
tmux kill-session -t runserver
|
|
```
|
|
|
|
## Run (human path)
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
.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`.
|