mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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>
195 lines
4.9 KiB
Go
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
|
|
}
|