#!/usr/bin/env bash # web-shot/shot.sh — screenshot a running web UI via a k3s playwright Job. # # Reusable across any Tailscale-connected host whose kubeconfig points at the # koala k3s cluster. See SKILL.md alongside this file for prereqs. set -euo pipefail # --- defaults -------------------------------------------------------------- PORT="" PATH_="" HOST="" URL="" SEARCH="" OUT_DIR="" JOB_PREFIX="web-shot" PLAYWRIGHT_TAG="v1.49.0-jammy" PLAYWRIGHT_NPM="1.49.0" NAMESPACE="default" usage() { sed -n '1,/^# ---/p' "$0" | sed 's/^# \{0,1\}//;1,/^!/d' >&2 cat >&2 <&2; usage ;; esac done # --- resolve URL ----------------------------------------------------------- if [ -z "$URL" ]; then if [ -z "$PORT" ]; then echo "✗ --port or --url is required" >&2 usage fi if [ -z "$HOST" ]; then if command -v tailscale >/dev/null 2>&1; then HOST="$(tailscale status --self --json 2>/dev/null | jq -r '.Self.HostName' 2>/dev/null || true)" fi if [ -z "$HOST" ] && [ -r /etc/hostname ]; then HOST="$(tr -d '\n' &2 exit 1 fi fi PATH_="${PATH_:-/}" case "$PATH_" in /*) ;; *) PATH_="/$PATH_" ;; esac URL="http://${HOST}:${PORT}${PATH_}" fi OUT_DIR="${OUT_DIR:-$(pwd)/screenshots}" mkdir -p "$OUT_DIR" JOB_NAME="${JOB_PREFIX}-$(date +%s)" echo "→ host=${HOST:-—} url=${URL} out=${OUT_DIR} job=${JOB_NAME}" # --- preflight: dev server must be reachable ------------------------------- # (Optional but cheap. Run from the caller, not the pod.) if ! curl -fsS --max-time 3 "${URL%/}/healthz" >/dev/null 2>&1 \ && ! curl -fsS --max-time 3 "$URL" >/dev/null 2>&1; then echo "⚠ ${URL} not reachable from this host. Pod will retry; if it fails, the dev server may be bound to 127.0.0.1 only." >&2 fi # --- emit + apply Job ------------------------------------------------------ TAKE_SEARCH="" [ -n "$SEARCH" ] && TAKE_SEARCH="yes" cleanup() { kubectl -n "$NAMESPACE" delete job "$JOB_NAME" --ignore-not-found=true >/dev/null 2>&1 || true } trap cleanup EXIT kubectl -n "$NAMESPACE" apply -f - >/dev/null </dev/null 2>&1 npm install playwright@${PLAYWRIGHT_NPM} --no-fund --no-audit --silent 2>&1 | tail -1 mkdir -p /shot cat > /tmp/s.mjs <<'JS' import { chromium } from 'playwright'; const url = process.env.URL; const out = process.env.OUT; const action = process.env.ACTION || ''; const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }); const p = await ctx.newPage(); await p.goto(url, { waitUntil: 'networkidle' }); if (action.startsWith('search:')) { const q = action.slice('search:'.length); await p.fill('input[name="q"]', q); await p.waitForResponse(r => r.url().includes('/search'), { timeout: 5000 }).catch(() => {}); await p.waitForTimeout(500); } await p.screenshot({ path: out, fullPage: true }); await b.close(); console.log('wrote', out); JS URL="\$URL" OUT=/shot/home.png ACTION= node /tmp/s.mjs if [ "\$DO_SEARCH" = "yes" ]; then URL="\$URL" OUT=/shot/search.png ACTION="search:\$SEARCH" node /tmp/s.mjs fi chmod 644 /shot/*.png echo "READY" # Window for kubectl cp; deleted by trap after we've grabbed files. sleep 90 YAML # --- wait for pod + READY marker ------------------------------------------ echo "→ waiting for pod scheduling…" for _ in $(seq 1 60); do POD="$(kubectl -n "$NAMESPACE" get pods -l "batch.kubernetes.io/job-name=$JOB_NAME" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" [ -n "$POD" ] && break sleep 1 done if [ -z "$POD" ]; then echo "✗ pod not scheduled" >&2; exit 1 fi echo "→ pod $POD" echo "→ waiting for READY (pulling image + rendering)…" for _ in $(seq 1 60); do if kubectl -n "$NAMESPACE" logs "$POD" 2>/dev/null | grep -q '^READY$'; then break fi PHASE="$(kubectl -n "$NAMESPACE" get pod "$POD" -o jsonpath='{.status.phase}' 2>/dev/null || true)" if [ "$PHASE" = "Failed" ]; then echo "✗ pod Failed — logs:" >&2 kubectl -n "$NAMESPACE" logs "$POD" 2>&1 | tail -30 >&2 exit 1 fi sleep 2 done # --- copy files out -------------------------------------------------------- kubectl -n "$NAMESPACE" cp "${NAMESPACE}/${POD}:/shot/home.png" "$OUT_DIR/home.png" 2>/dev/null \ || { echo "✗ kubectl cp home.png failed" >&2; exit 1; } if [ "$TAKE_SEARCH" = "yes" ]; then kubectl -n "$NAMESPACE" cp "${NAMESPACE}/${POD}:/shot/search.png" "$OUT_DIR/search.png" 2>/dev/null \ || { echo "✗ kubectl cp search.png failed" >&2; exit 1; } fi ls -la "$OUT_DIR"/*.png echo "✓ screenshots in $OUT_DIR"