feat(screenshots): add headless-Chrome capture pipeline with fake speaker

Refreshes docs/images/ui-{settings,devices,sync,migration}.png by
driving the web UI in chromedp against a synthetic speaker, so
documentation can be regenerated without real hardware and without
leaking personal data from the local network.

Three independent pieces:

- pkg/service/testing/fakespeaker — embeddable library serving the
  HTTP and telnet surface the migration wizard probes (/info,
  /presets, /recents and a getpdo CurrentSystemConfiguration reply
  that places the device on the unmigrated happy path).
- cmd/dummy-speaker — thin CLI wrapping the library; self-registers
  with a running service via POST /setup/devices.
- scripts/screenshots — chromedp runner driven by a JSON manifest;
  decoupled from speaker/service setup so it can target any backend
  URL. run.sh orchestrates a one-shot end-to-end capture and seeds
  settings.json with a generic hostname plus discovery disabled to
  keep real-network state out of the captures.

Captures are at DPR=2 for retina-sharp text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-11 20:37:23 +02:00
co-authored by Claude Opus 4.7
parent 0e8ab1cd89
commit bb71253690
19 changed files with 952 additions and 1 deletions
+194
View File
@@ -0,0 +1,194 @@
// Command screenshots drives a headless Chrome via chromedp to capture
// PNG screenshots of the soundtouch-service web UI for documentation.
//
// It is deliberately decoupled from any speaker/service setup: callers
// are responsible for having the service reachable at --base and any
// required devices already registered. See cmd/dummy-speaker for a
// matching no-hardware backend.
//
// Manifest format (JSON):
//
// {
// "shots": [
// {
// "name": "ui-settings",
// "path": "/web/",
// "click_selector": "button[onclick*=\"tab-settings\"]",
// "wait_selector": "#tab-settings.active",
// "viewport": {"width": 1280, "height": 900},
// "settle_ms": 250
// }
// ]
// }
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/chromedp/chromedp"
)
type viewport struct {
Width int `json:"width"`
Height int `json:"height"`
Scale float64 `json:"scale"`
}
type shot struct {
Name string `json:"name"`
Path string `json:"path"`
ClickSelector string `json:"click_selector,omitempty"`
WaitSelector string `json:"wait_selector,omitempty"`
Evaluate string `json:"evaluate,omitempty"` // JS to run after the click (e.g. to programmatically select a device + trigger summary)
WaitAfterEval string `json:"wait_after_eval,omitempty"` // selector to wait for once the JS evaluation has completed
Viewport viewport `json:"viewport,omitempty"`
SettleMs int `json:"settle_ms,omitempty"`
}
type manifest struct {
Shots []shot `json:"shots"`
}
func main() {
base := flag.String("base", "http://localhost:8000", "service base URL")
manifestPath := flag.String("manifest", "scripts/screenshots/manifest.json", "path to shot manifest JSON")
outDir := flag.String("out", "docs/images", "output directory for PNGs")
timeoutSec := flag.Int("timeout", 30, "per-shot timeout (seconds)")
flag.Parse()
m, err := readManifest(*manifestPath)
if err != nil {
log.Fatalf("read manifest: %v", err)
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
log.Fatalf("mkdir %s: %v", *outDir, err)
}
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
chromedp.Flag("hide-scrollbars", true),
)...)
defer cancelAlloc()
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
defer cancelBrowser()
if err := chromedp.Run(browserCtx); err != nil {
log.Fatalf("launch browser: %v", err)
}
failed := 0
for _, sh := range m.Shots {
log.Printf("capturing %s", sh.Name)
if err := capture(browserCtx, *base, *outDir, sh, time.Duration(*timeoutSec)*time.Second); err != nil {
log.Printf(" failed: %v", err)
failed++
continue
}
log.Printf(" ok")
}
if failed > 0 {
log.Fatalf("%d shot(s) failed", failed)
}
}
func readManifest(path string) (*manifest, error) {
raw, err := os.ReadFile(path) //nolint:gosec
if err != nil {
return nil, err
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &m, nil
}
func capture(parent context.Context, baseURL, outDir string, sh shot, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
w, h := sh.Viewport.Width, sh.Viewport.Height
if w == 0 {
w = 1280
}
if h == 0 {
h = 900
}
scale := sh.Viewport.Scale
if scale == 0 {
scale = 2 // retina-equivalent DPR; sharper text in captured PNGs
}
settle := time.Duration(sh.SettleMs) * time.Millisecond
if settle == 0 {
settle = 200 * time.Millisecond
}
tabCtx, tabCancel := chromedp.NewContext(ctx)
defer tabCancel()
url := baseURL + sh.Path
actions := []chromedp.Action{
chromedp.EmulateViewport(int64(w), int64(h), chromedp.EmulateScale(scale)),
chromedp.Navigate(url),
chromedp.WaitReady("body", chromedp.ByQuery),
}
if sh.ClickSelector != "" {
actions = append(actions,
chromedp.WaitVisible(sh.ClickSelector, chromedp.ByQuery),
chromedp.Click(sh.ClickSelector, chromedp.ByQuery),
)
}
if sh.WaitSelector != "" {
actions = append(actions, chromedp.WaitVisible(sh.WaitSelector, chromedp.ByQuery))
}
if sh.Evaluate != "" {
actions = append(actions, chromedp.Evaluate(sh.Evaluate, nil))
}
if sh.WaitAfterEval != "" {
actions = append(actions, chromedp.WaitVisible(sh.WaitAfterEval, chromedp.ByQuery))
}
actions = append(actions, chromedp.Sleep(settle))
var buf []byte
actions = append(actions, chromedp.FullScreenshot(&buf, 100))
if err := chromedp.Run(tabCtx, actions...); err != nil {
return fmt.Errorf("chromedp: %w", err)
}
outPath := filepath.Join(outDir, sh.Name+".png")
if err := os.WriteFile(outPath, buf, 0o644); err != nil { //nolint:gosec
return fmt.Errorf("write %s: %w", outPath, err)
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
{
"shots": [
{
"name": "ui-settings",
"path": "/",
"click_selector": "button[onclick*=\"tab-settings\"]",
"wait_selector": "#tab-settings.active",
"settle_ms": 300
},
{
"name": "ui-devices",
"path": "/",
"click_selector": "button[onclick*=\"tab-devices\"]",
"wait_selector": "#tab-devices.active",
"settle_ms": 500
},
{
"name": "ui-sync",
"path": "/",
"click_selector": "button[onclick*=\"tab-sync\"]",
"wait_selector": "#tab-sync.active",
"settle_ms": 300
},
{
"name": "ui-migration",
"path": "/",
"click_selector": "button[onclick*=\"tab-migration\"]",
"wait_selector": "#tab-migration.active",
"evaluate": "prepareMigration('DEADBEEFCAFE')",
"wait_after_eval": "#migration-summary",
"settle_ms": 4000
}
]
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Orchestrates an end-to-end screenshot capture: spins up a clean
# soundtouch-service + dummy-speaker, drives the web UI in headless
# Chrome via the chromedp runner, then tears everything down.
#
# Outputs to docs/images/ by default. Override with OUT_DIR=/some/path.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO_ROOT"
OUT_DIR="${OUT_DIR:-docs/images}"
SERVICE_PORT="${SERVICE_PORT:-8000}"
SPEAKER_PORT="${SPEAKER_PORT:-8090}"
DATA_DIR="$(mktemp -d -t soundtouch-screenshots-XXXXXX)"
LOG_DIR="$(mktemp -d -t soundtouch-screenshot-logs-XXXXXX)"
SERVICE_PID=""
SPEAKER_PID=""
cleanup() {
set +e
if [ -n "$SPEAKER_PID" ] && kill -0 "$SPEAKER_PID" 2>/dev/null; then
kill "$SPEAKER_PID"
wait "$SPEAKER_PID" 2>/dev/null
fi
if [ -n "$SERVICE_PID" ] && kill -0 "$SERVICE_PID" 2>/dev/null; then
kill "$SERVICE_PID"
wait "$SERVICE_PID" 2>/dev/null
fi
rm -rf "$DATA_DIR"
echo "logs retained at $LOG_DIR"
}
trap cleanup EXIT
echo "==> building binaries"
go build -o "$LOG_DIR/soundtouch-service" ./cmd/soundtouch-service
go build -o "$LOG_DIR/dummy-speaker" ./cmd/dummy-speaker
go build -o "$LOG_DIR/screenshots" ./scripts/screenshots
echo "==> seeding settings.json (generic hostname + discovery off to avoid leaking real network info)"
cat > "$DATA_DIR/settings.json" <<'EOF'
{
"server_url": "http://aftertouch.local:8000",
"https_server_url": "https://aftertouch.local:8443",
"discovery_enabled": false,
"discovery_interval": "1h"
}
EOF
echo "==> starting soundtouch-service on :$SERVICE_PORT (data: $DATA_DIR)"
"$LOG_DIR/soundtouch-service" --port "$SERVICE_PORT" --data-dir "$DATA_DIR" \
> "$LOG_DIR/service.log" 2>&1 &
SERVICE_PID=$!
echo "==> waiting for service to be ready"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:$SERVICE_PORT/setup/devices" > /dev/null 2>&1; then
break
fi
if ! kill -0 "$SERVICE_PID" 2>/dev/null; then
echo "service died early; log tail:"
tail -40 "$LOG_DIR/service.log"
exit 1
fi
sleep 0.5
done
echo "==> starting dummy-speaker on :$SPEAKER_PORT (registering with service)"
# Register as bare IP (no port) so the service appends :8090 for HTTP and
# :17000 for telnet exactly the way it does with real hardware. This is
# also why the listeners below bind to the canonical Bose ports.
"$LOG_DIR/dummy-speaker" \
--listen "127.0.0.1:$SPEAKER_PORT" \
--telnet-listen "127.0.0.1:17000" \
--register "http://127.0.0.1:$SERVICE_PORT" \
--register-as "127.0.0.1" \
> "$LOG_DIR/speaker.log" 2>&1 &
SPEAKER_PID=$!
sleep 1
if ! kill -0 "$SPEAKER_PID" 2>/dev/null; then
echo "dummy-speaker died early; log tail:"
tail -40 "$LOG_DIR/speaker.log"
exit 1
fi
echo "==> capturing screenshots into $OUT_DIR"
"$LOG_DIR/screenshots" \
--base "http://127.0.0.1:$SERVICE_PORT" \
--manifest scripts/screenshots/manifest.json \
--out "$OUT_DIR"
echo "==> done"