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.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: make-widget
|
||||
description: Scaffold a new widget type for the espresso_frame server (the ~13-file checklist a widget type touches -- config table, grid footprint, render module, registry, migration, config-save + endpoints, dialog template + JS, script tag, WIDGET_LABELS, docs, tests). Use when asked to add a new widget type to a frame's panel (e.g. "add a text widget", "add an RSS widget", "add a weather-only widget").
|
||||
---
|
||||
|
||||
Adding a widget type is a very consistent, repeated pattern in this
|
||||
codebase (`photos`/`calendar`/`whiteboard`/`tasks`/`static`) -- see
|
||||
`docs/widgets.md` for the system's actual data model/rendering/dialog
|
||||
architecture (read that first if you haven't). This skill is the
|
||||
checklist of every file that pattern touches, so nothing gets silently
|
||||
dropped (the static-image widget shipped without a `docs/widgets.md`
|
||||
update; this skill exists so that doesn't keep happening).
|
||||
|
||||
**For a complete worked example touching every item below**, `git show
|
||||
35e80c6 --stat` (the static-image widget's commit) in this repo.
|
||||
|
||||
All paths below are relative to `server/`.
|
||||
|
||||
## Before writing any code: shape decisions
|
||||
|
||||
Answer these first -- they determine which existing widget type is the
|
||||
closest template to copy from:
|
||||
|
||||
- **Live upstream to poll, or self-contained/user-authored?** Calendar/
|
||||
whiteboard/tasks fetch from somewhere external on a throttle
|
||||
(`checked_at` + `get_or_refresh_*` in `routers/common.py`). Photos'
|
||||
queue and the static image widget don't -- their content is set once
|
||||
via the dialog (an upload, a pick) and just sits there until changed.
|
||||
A text widget is almost certainly this second shape.
|
||||
- **Single source, or multi-source merge?** Calendar/tasks merge
|
||||
several *people's* data (`FrameCalendar`/`FrameTaskList`, owner-adds/
|
||||
anyone-mutes). Only reach for that shape if the new type genuinely
|
||||
needs to combine several linked users' own data -- most new widget
|
||||
types are single-owner/single-config and don't need it.
|
||||
- **Any button actions**, or is `ACTIONS = {}` correct (nothing to
|
||||
advance/back/force)? Tasks and static image are both `{}`.
|
||||
- **Minimum sane grid footprint** -- how small can this widget be
|
||||
before its content is illegible/pointless?
|
||||
|
||||
Pick your template accordingly:
|
||||
|
||||
| New widget shape | Copy from |
|
||||
|---|---|
|
||||
| Self-contained, user-authored/uploaded, no fetch, no actions | `app/widgets/static_image.py` |
|
||||
| Single external source, throttled fetch, one "check_now" action | `app/widgets/whiteboard.py` |
|
||||
| Multi-source merge, owner-adds/anyone-mutes permissions | `app/widgets/tasks.py` (simpler) or `calendar.py` (also has size-tier rendering) |
|
||||
| Stateful queue/rotation with advance/back | `app/widgets/photos.py` |
|
||||
|
||||
## The checklist
|
||||
|
||||
1. **`app/models.py`** -- new `<Type>WidgetConfig` table, `widget_id`
|
||||
`Mapped[int]` primary key `ForeignKey("widgets.id", ondelete="CASCADE")`,
|
||||
plus whatever fields the type needs. Add it to the `WIDGET_CONFIG_MODELS`
|
||||
dict at the bottom of the file.
|
||||
2. **`app/grid.py`** -- add an entry to `MIN_FOOTPRINT`.
|
||||
3. **`app/widgets/<type>.py`** -- new module exposing:
|
||||
- `render(db, frame, widget, target_w, target_h, is_normal_wake=True) -> Image.Image`
|
||||
-- RGB, exactly `target_w x target_h`, **never raises** for a
|
||||
foreseeable failure (missing config, fetch error) -- fall back to
|
||||
`._shared.placeholder_image(target_w, target_h, [lines])` instead.
|
||||
- `ACTIONS: dict[str, Callable[[Session, Frame, Widget], None]]`
|
||||
- `ACTION_LABELS: dict[str, str]`
|
||||
4. **`app/widgets/__init__.py`** -- import the new module, add it to
|
||||
`WIDGET_TYPES`.
|
||||
5. **`app/migration.py`** -- new `_migration_N`. A brand-new table with
|
||||
no legacy data to carry forward is just
|
||||
`Base.metadata.create_all(bind=conn)` (see `_migration_20`) -- it
|
||||
only creates the one new table, existing ones are untouched. Register
|
||||
`(N, _migration_N)` as the new last entry in `MIGRATIONS`.
|
||||
6. **`app/routers/api_widgets.py`**:
|
||||
- Add any new `Form(...)` fields to `api_widget_config_save`'s
|
||||
signature, and a new `elif widget.widget_type == "<type>":` branch
|
||||
inside its body. Reuse an existing field name (e.g. `display_mode`)
|
||||
where the semantics genuinely match -- fields are namespaced by
|
||||
which widget type actually reads them, not by name collision, so
|
||||
this is safe (see the comment above `display_mode` in that
|
||||
function).
|
||||
- Add type-specific endpoints as needed (upload/source-select/etc.).
|
||||
Use `require_widget_control` for widget-wide settings a dialog Save
|
||||
button changes; use `require_widget_view` (not control) for the
|
||||
owner-adds/anyone-mutes multi-source pattern, matching
|
||||
`api_widget_calendar_select`/`api_widget_task_list_select`.
|
||||
- Add a `GET .../preview/<type>` endpoint mirroring the others --
|
||||
`render_preview_png` (the full palette/dither pipeline) for
|
||||
image-like content, or a dedicated `render_<type>_preview_png` in a
|
||||
rendering module for text/graphics content (see
|
||||
`calendar_render.render_tasks_preview_png`).
|
||||
7. **`app/routers/frame_pages.py`** -- import the new config model, add
|
||||
an `if widget.widget_type == "<type>":` branch in `widget_dialog()`
|
||||
returning `templates.TemplateResponse("_widget_dialog_<type>.html", {...})`.
|
||||
8. **`app/templates/_widget_dialog_<type>.html`** -- the dialog
|
||||
fragment: settings card(s) + `<img class="preview-img"
|
||||
id="<type>-preview">` + a refresh button, using the existing
|
||||
`.card`/`.card-title`/`.sub`/`.checkbox-row` classes from
|
||||
`theme.css` rather than inventing new ones.
|
||||
9. **`app/static/widget_dialog_<type>.js`** -- an `init<Type>Dialog()`/
|
||||
`close<Type>Dialog()` pair (not a page-load script -- see any
|
||||
existing `widget_dialog_*.js`'s header comment for the contract).
|
||||
`window.fetch` already CSRF-injects (see `common.js`), so POSTs don't
|
||||
need a manual header. **Never build user-supplied text into the DOM
|
||||
via `innerHTML` string interpolation** -- use `textContent`/
|
||||
`createElement` (a filename, a task summary, anything another linked
|
||||
user's account could have set is a stored-XSS vector otherwise).
|
||||
10. **`app/templates/frame_layout.html`** -- add
|
||||
`<script src="/static/widget_dialog_<type>.js"></script>` next to
|
||||
the other widget dialog scripts.
|
||||
11. **`app/static/frame_layout.js`** -- add the type to both
|
||||
`DIALOG_INIT` and `DIALOG_CLOSE`.
|
||||
12. **`app/static/common.js`** -- add a `WIDGET_LABELS` entry (the
|
||||
human label shown in the add-widget button, the widget box, and the
|
||||
button-assignment picker in `frame_config.js`).
|
||||
13. **`docs/widgets.md`** -- update every place that enumerates widget
|
||||
types: the intro sentence, the `widget_type` column-value list, the
|
||||
`MIN_FOOTPRINT` prose line, the `app/widgets/` module list. This is
|
||||
the project's own "start here" doc per `CLAUDE.md` -- don't ship a
|
||||
widget without it staying accurate.
|
||||
|
||||
## Tests (`server/tests/`)
|
||||
|
||||
- `test_widgets_<type>.py` -- unit-level `render()` tests, no HTTP:
|
||||
correct size/mode with no config, with config, at
|
||||
`grid.MIN_FOOTPRINT`'s smallest box, `ACTIONS == {}` if passive.
|
||||
Mirror `test_widgets_static.py` (self-contained) or
|
||||
`test_widgets_tasks.py` (fetch-backed, monkeypatches the fetch call).
|
||||
- `test_widget_config_and_queue_endpoints.py` -- add a
|
||||
`_add_<type>_widget` helper plus an HTTP-level
|
||||
`test_config_save_updates_a_<type>_widget` test, and tests for any new
|
||||
endpoints (upload/select/preview: 400 before configured, 200 after,
|
||||
400 for the wrong widget type via `_require_widget_type`).
|
||||
- `test_migrations.py` -- add the new table to
|
||||
`test_expected_columns_exist_on_current_schema`'s spot-check
|
||||
(`inspector.get_table_names()` or `inspector.get_columns(...)`).
|
||||
- Owner-adds/anyone-mutes multi-source table? Add cases to
|
||||
`test_permission_boundaries.py` following its existing
|
||||
calendar-select/task-list-select pattern (owner can add, non-owner
|
||||
can mute but not add, 404 for an unrelated widget id, 400 for the
|
||||
wrong widget type).
|
||||
- Any pure-logic helper module (decoding, parsing -- like
|
||||
`app/image_upload.py`) gets its own `test_<module>.py`: no HTTP, no
|
||||
DB, just the function.
|
||||
|
||||
Run the full suite before calling it done:
|
||||
|
||||
```bash
|
||||
cd server && .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
Comfortably under 30s for the whole suite (~200+ tests) -- there's no
|
||||
reason to skip this or run a subset.
|
||||
|
||||
## Browser verification (required, not optional)
|
||||
|
||||
Per `CLAUDE.md`, reading the JS is not enough -- this project has
|
||||
shipped UI bugs (mobile viewport CSS collapse, a dialog's status message
|
||||
landing behind its own backdrop, a JSON/form body mismatch) that only
|
||||
showed up live. Use the `run-server` skill:
|
||||
|
||||
- Clear existing widgets and add one of the new type
|
||||
(`POST /api/frames/1/widgets`), resize it (`PATCH`), open its dialog
|
||||
(`click .widget-box-settings`), exercise its actual settings/upload
|
||||
flow through the real UI controls (not just a raw `fetch` in `eval` --
|
||||
that only proves the endpoint works, not that the button is wired to
|
||||
it), and check `console-errors` for anything beyond the expected
|
||||
favicon 404.
|
||||
- Check the full composited panel preview
|
||||
(`#frame-preview-thumb` on `/frames/{id}/config`) actually shows the
|
||||
new widget's content -- not just its own dialog's `preview/<type>`
|
||||
image, which only proves the render function works in isolation.
|
||||
- Screenshot **both** desktop (`viewport 1280 900`) and mobile
|
||||
(the driver's default) widths -- the layout genuinely forks at the
|
||||
860px breakpoint in `theme.css`.
|
||||
|
||||
## Commit
|
||||
|
||||
One commit for the whole widget (models + migration + render + router +
|
||||
UI + tests + docs) -- this project's convention is one feature per
|
||||
commit, not split by layer. No `Co-Authored-By: Claude` trailer (see
|
||||
root `CLAUDE.md`).
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated by setup.sh -- bakes in this host's /tmp paths, not portable.
|
||||
env.sh
|
||||
@@ -0,0 +1,234 @@
|
||||
---
|
||||
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.
|
||||
|
||||
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
|
||||
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 && 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:
|
||||
|
||||
```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
|
||||
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`.
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/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)
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time (idempotent) environment bootstrap for running the
|
||||
# espresso_frame FastAPI server and browser-driving its UI, in a
|
||||
# container that ships with NO Python/Node/Docker/browser and NO sudo.
|
||||
# Re-run any time; every step checks whether it already happened.
|
||||
set -euo pipefail
|
||||
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
|
||||
|
||||
UV_BIN="$HOME/.local/bin/uv"
|
||||
DEPS_ROOT="/tmp/run-server-chromium-deps"
|
||||
APT_WORK="/tmp/apt-work-run-server"
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$SKILL_DIR/env.sh"
|
||||
|
||||
# 1. uv: a static Rust binary that can fetch its own Python build with
|
||||
# no C compiler needed (this container has none).
|
||||
if [ ! -x "$UV_BIN" ]; then
|
||||
echo "installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
fi
|
||||
|
||||
# 2. Python 3.12 + venv + server deps
|
||||
if [ ! -x .venv/bin/uvicorn ]; then
|
||||
echo "creating venv + installing server deps..."
|
||||
"$UV_BIN" python install 3.12
|
||||
"$UV_BIN" venv --python 3.12 .venv
|
||||
"$UV_BIN" pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# 3. Playwright (Python) + its Chromium download (~280MB: full chrome +
|
||||
# chrome-headless-shell + ffmpeg)
|
||||
if ! .venv/bin/python -c "import playwright" 2>/dev/null; then
|
||||
echo "installing playwright..."
|
||||
"$UV_BIN" pip install playwright
|
||||
fi
|
||||
if ! ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome >/dev/null 2>&1; then
|
||||
echo "downloading chromium..."
|
||||
.venv/bin/playwright install chromium
|
||||
fi
|
||||
|
||||
# 4. Chromium's shared libs + fonts. `playwright install-deps` and
|
||||
# `apt-get install` both need root; neither is available. Instead:
|
||||
# download the .deb files directly (apt-get download works read-only
|
||||
# without root once given a user-writable state dir) and extract
|
||||
# (not install) them with dpkg-deb -x, which needs no root either.
|
||||
if [ ! -f "$DEPS_ROOT/usr/lib/x86_64-linux-gnu/libglib-2.0.so.0" ]; then
|
||||
echo "fetching chromium's shared libs + fonts (non-root)..."
|
||||
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
|
||||
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null update
|
||||
|
||||
PKGS="libglib2.0-0t64 libnspr4 libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64
|
||||
libdbus-1-3 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1
|
||||
libxkbcommon0 libasound2t64 libatspi2.0-0t64 libcups2t64 libcairo2
|
||||
libpango-1.0-0 libpangocairo-1.0-0 libx11-6 libxcb1 libxext6
|
||||
fonts-liberation fontconfig libfontconfig1"
|
||||
# ^ first row: what chrome-headless-shell's ldd reported missing.
|
||||
# second row: what the FULL chrome binary additionally needed (we use
|
||||
# full chrome, not headless-shell -- see Gotchas in SKILL.md).
|
||||
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
|
||||
--print-uris $PKGS | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/urls.txt"
|
||||
|
||||
(cd "$APT_WORK/debs" && xargs -n1 -P8 curl -sS -O --max-time 30) < "$APT_WORK/urls.txt"
|
||||
for f in "$APT_WORK"/debs/*.deb; do dpkg-deb -x "$f" "$DEPS_ROOT"; done
|
||||
|
||||
# fonts.conf as shipped points at the real /usr/share/fonts, which is
|
||||
# root-owned and has nothing extracted into it. Point it at our
|
||||
# extracted copy instead, and give it a writable cache dir.
|
||||
mkdir -p /tmp/run-server-fontcache
|
||||
sed -i "s#<dir>/usr/share/fonts</dir>#<dir>$DEPS_ROOT/usr/share/fonts</dir>#" \
|
||||
"$DEPS_ROOT/etc/fonts/fonts.conf"
|
||||
sed -i "s#<cachedir>.*</cachedir>#<cachedir>/tmp/run-server-fontcache</cachedir>#" \
|
||||
"$DEPS_ROOT/etc/fonts/fonts.conf"
|
||||
|
||||
PATH="$DEPS_ROOT/usr/bin:$PATH" \
|
||||
LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu" \
|
||||
FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts" \
|
||||
"$DEPS_ROOT/usr/bin/fc-cache" -f
|
||||
fi
|
||||
|
||||
# 5. tmux -- also missing, also no apt/sudo. Same non-root download +
|
||||
# dpkg-deb -x trick, into the same extracted root (so its `usr/bin` is
|
||||
# already on PATH via env.sh).
|
||||
if [ ! -f "$DEPS_ROOT/usr/bin/tmux" ]; then
|
||||
echo "fetching tmux (non-root)..."
|
||||
mkdir -p "$APT_WORK/lists" "$APT_WORK/cache/archives/partial" "$APT_WORK/debs" "$DEPS_ROOT"
|
||||
apt-get -o Dir::State::Lists="$APT_WORK/lists" -o Dir::Cache="$APT_WORK/cache" \
|
||||
-o Dir::Etc::SourceParts=/dev/null install --download-only --reinstall -y \
|
||||
--print-uris tmux | grep -oP "^'[^']+'" | tr -d "'" > "$APT_WORK/tmux_urls.txt"
|
||||
(cd "$APT_WORK/debs" && xargs -n1 -P3 curl -sS -O --max-time 30) < "$APT_WORK/tmux_urls.txt"
|
||||
for f in $(sed -E 's#.*/##' "$APT_WORK/tmux_urls.txt"); do dpkg-deb -x "$APT_WORK/debs/$f" "$DEPS_ROOT"; done
|
||||
fi
|
||||
|
||||
CHROME_BIN="$(ls "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | head -1)"
|
||||
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Generated by setup.sh. Source this before running driver.py (it
|
||||
# needs LD_LIBRARY_PATH/FONTCONFIG_PATH set before the Chromium
|
||||
# subprocess launches -- driver.py does not source it for you).
|
||||
# start-server.sh does NOT need this file -- uvicorn has no such deps.
|
||||
export PATH="\$HOME/.local/bin:$DEPS_ROOT/usr/bin:\$PATH"
|
||||
export LD_LIBRARY_PATH="$DEPS_ROOT/usr/lib/x86_64-linux-gnu:$DEPS_ROOT/lib/x86_64-linux-gnu"
|
||||
export FONTCONFIG_PATH="$DEPS_ROOT/etc/fonts"
|
||||
export RUN_SERVER_CHROME_BIN="$CHROME_BIN"
|
||||
EOF
|
||||
|
||||
echo "setup complete -> $ENV_FILE"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Background-launch the server against a scratch DB/config -- never the
|
||||
# real deployment's data (see CLAUDE.md). Waits for readiness, prints
|
||||
# the PID and log path. Usage: ./start-server.sh [scratch-dir] [port]
|
||||
set -euo pipefail
|
||||
cd "$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)/server"
|
||||
|
||||
SCRATCH="${1:-/tmp/run-server-scratch}"
|
||||
PORT="${2:-8420}"
|
||||
mkdir -p "$SCRATCH"
|
||||
|
||||
DATABASE_URL="sqlite:///$SCRATCH/test.db" \
|
||||
CONFIG_PATH="$SCRATCH/config.json" \
|
||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port "$PORT" \
|
||||
> "$SCRATCH/server.log" 2>&1 &
|
||||
PID=$!
|
||||
echo "$PID" > "$SCRATCH/server.pid"
|
||||
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:$PORT/" && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ! curl -sf -o /dev/null "http://127.0.0.1:$PORT/"; then
|
||||
echo "server did not become ready -- check $SCRATCH/server.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "server PID $PID up on http://127.0.0.1:$PORT (log: $SCRATCH/server.log, db: $SCRATCH/test.db)"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./stop-server.sh [scratch-dir]
|
||||
SCRATCH="${1:-/tmp/run-server-scratch}"
|
||||
if [ -f "$SCRATCH/server.pid" ]; then
|
||||
kill "$(cat "$SCRATCH/server.pid")" 2>/dev/null || true
|
||||
rm -f "$SCRATCH/server.pid"
|
||||
echo "stopped"
|
||||
else
|
||||
echo "no $SCRATCH/server.pid -- nothing to stop"
|
||||
fi
|
||||
Reference in New Issue
Block a user