Files
T
Tobias GesellchenandClaude Opus 4.7 bb71253690 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>
2026-05-11 20:37:23 +02:00

195 lines
4.9 KiB
Go

// 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
}