docs: add sandbox-browser-automation skill

Documents what's actually installed for browser automation in this sandbox
(Playwright via Python, no global Node package, browsers pre-cached) and a
DNS-routing quirk on *.palmasolutions.net domains, plus the circular
overflow:hidden badge-clipping bug class found while debugging sateulera-dev.
This commit is contained in:
2026-08-20 21:43:56 +02:00
parent 3b288c0a21
commit ebc71fdcd0
@@ -0,0 +1,91 @@
---
name: sandbox-browser-automation
description: Use when you need to actually render a page (screenshot, computed styles, DOM inspection) instead of guessing from curl/static file inspection — e.g. "the CSS fix is deployed but the user still says it's not showing." Documents what's really installed in this sandbox and a live DNS-routing quirk that affects `*.palmasolutions.net` fleet domains specifically.
---
# Sandbox Browser Automation
Static HTTP/CSS inspection cannot prove what actually renders — clipping,
cascade wins, and z-index/overflow interactions only show up in a real
browser. This sandbox has Playwright fully installed; use it before
concluding "the fix is deployed, must be a caching issue on the user's end."
## What's actually installed (verified 2026-08-20)
- **Playwright 1.62.1**, browsers pre-downloaded at `~/.cache/ms-playwright/`
(chromium + ffmpeg). No re-download needed.
- **Python path (preferred — no setup required):** the `playwright` package
is globally importable — `python3 -c "from playwright.sync_api import
sync_playwright"` works immediately, no venv/install step. `/usr/local/bin/playwright`
is this same package's CLI (`playwright codegen`, etc.).
- **Node path:** no *global* npm `playwright` package — a script needs its
own local `node_modules/playwright` to `require()` it. Check `/tmp/` first
for a scratch dir a prior session may have already set up (e.g. past names
seen: `sateulera-render`, `mobisol-render`, `st-pw-package`) before running
a fresh `npm install playwright` — reusing one skips the install entirely
since the browser binaries are already cached globally. `/tmp` is shared
across concurrent sandboxes/sessions and not durable — don't assume a given
scratch dir still exists, always `ls` first, and don't rely on one surviving
into a future session.
- No system `chromium`/`google-chrome` binary on `PATH` — always go through
Playwright's own managed browser, not a system browser launch.
Default to the Python one-shot form when you just need a screenshot or a
computed-style check — it has zero setup cost. Reach for a Node script only
when reusing an existing scratch project's `node_modules` is actually faster,
or the task needs a JS-heavy interaction sequence.
```python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_context(viewport={"width": 375, "height": 812},
ignore_https_errors=True).new_page()
page.goto("https://example.com/", wait_until="domcontentloaded", timeout=45000)
page.screenshot(path="/root/.claude/jobs/<job>/tmp/out.png")
browser.close()
```
Use `wait_until="domcontentloaded"` by default, not `"networkidle"`
sites with long-polling/analytics connections can hang `networkidle` well
past a reasonable timeout.
## `*.palmasolutions.net` (and possibly other fleet domains) resolve differently inside this sandbox
Confirmed 2026-08-20 on `sateulera-dev.palmasolutions.net`: `dig` from this
box returns the real public path (madri's Caddy IP, `195.20.235.141`), but
the box's own system resolver — what `curl`, Node, and Playwright actually
use via `getaddrinfo` — resolves the same name **directly to the backend's
Tailscale IP** (funky, `100.127.21.100`), bypassing Caddy entirely. This is
likely a split-DNS/hosts entry specific to this sandbox's network setup, not
present for the end user's real browser.
In this case the content served both ways turned out to be byte-identical
(verified via `md5sum`), so it wasn't the cause of a "still broken" report —
but don't assume that holds for every fleet domain or every time. If a fix
verifies correctly in Playwright from this box but the user still reports it
broken, explicitly re-check via the real public path before ruling out a
routing/caching difference:
```bash
curl -sk --resolve <domain>:443:<public-ip-from-dig> "https://<domain>/path" | md5sum
curl -sk "https://<domain>/path" | md5sum # this box's default (possibly-shortcut) path
```
If the hashes differ, you've found a real routing/cache split, not a false
alarm — investigate the reverse proxy (Caddy on madri, or nginx on proxie)
rather than the origin.
## Real bug class this catches that static inspection misses
A badge/element positioned with a negative offset (`top:0;left:-2px`, etc.)
to overlap the corner of a **circular** `overflow:hidden` + `border-radius:50%`
container gets clipped by the circle's curve even while the badge's own
rectangular bounding box is still inside the container's rectangular bounds.
`border-radius` and `padding` fixes on the badge itself cannot fix this —
computed-style inspection via `getComputedStyle` will even report the badge's
own box as unclipped (`overflow: visible` on the badge). Only a rendered
screenshot (or checking the *parent's* computed `overflow`) reveals it. Fix
is to drop `overflow:hidden` from the circular parent (the inner icon
element usually already carries its own `border-radius:50%` and doesn't need
the outer clip).