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
@@ -0,0 +1,139 @@
// Package fakespeaker runs a minimal HTTP server that impersonates the
// SoundTouch device's :8090 API surface with sanitized, embedded fixture
// data. It exists so docs/screenshot tooling and integration setups can
// register a "speaker" without depending on real hardware or leaking
// personal data into committed artifacts.
//
// The fixture set is deliberately narrow: enough for the soundtouch-service
// to accept device registration and render initial UI views. Extend the
// route set as additional pre-flight or migration flows need coverage.
package fakespeaker
import (
"context"
"embed"
"errors"
"fmt"
"net"
"net/http"
"time"
)
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml
var fixtures embed.FS
// Config configures a fake speaker. The zero value is valid and binds the
// HTTP API to a random port on 127.0.0.1 with no telnet listener.
type Config struct {
// HTTPListen is the bind address for the device's :8090 HTTP API
// (e.g. "127.0.0.1:8090" or ":8090"). Empty means "127.0.0.1:0" —
// let the OS pick a port.
HTTPListen string
// TelnetListen is the bind address for the device's :17000
// diagnostic shell. Empty disables the telnet listener entirely.
// Use "127.0.0.1:17000" to match the real port the wizard probes.
TelnetListen string
}
// Server is a running fake speaker. It bundles whichever sub-servers
// were enabled in the Config; consult HTTPAddr / TelnetAddr to discover
// where they actually bound.
type Server struct {
srv *http.Server
httpAddr string
telnet *telnetServer
}
// Start binds the configured listeners and serves them in background
// goroutines. It returns once they are ready (so callers can immediately
// use the resolved addresses) or with an error if any bind failed.
func Start(cfg Config) (*Server, error) {
httpListen := cfg.HTTPListen
if httpListen == "" {
httpListen = "127.0.0.1:0"
}
ln, err := net.Listen("tcp", httpListen)
if err != nil {
return nil, fmt.Errorf("fakespeaker: listen %s: %w", httpListen, err)
}
mux := http.NewServeMux()
registerRoutes(mux)
s := &Server{
srv: &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
},
httpAddr: ln.Addr().String(),
}
go func() {
_ = s.srv.Serve(ln)
}()
if cfg.TelnetListen != "" {
ts, terr := startTelnetServer(cfg.TelnetListen)
if terr != nil {
_ = s.srv.Close()
return nil, terr
}
s.telnet = ts
}
return s, nil
}
// HTTPAddr returns the resolved HTTP listen address as "host:port".
func (s *Server) HTTPAddr() string {
return s.httpAddr
}
// TelnetAddr returns the resolved telnet listen address as "host:port",
// or "" if the telnet listener is disabled.
func (s *Server) TelnetAddr() string {
if s.telnet == nil {
return ""
}
return s.telnet.Addr()
}
// Stop shuts all sub-servers down, blocking until in-flight requests
// finish or ctx is cancelled.
func (s *Server) Stop(ctx context.Context) error {
if s.telnet != nil {
s.telnet.Stop()
}
if err := s.srv.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/info", serveFixture("testdata/info.xml"))
mux.HandleFunc("/presets", serveFixture("testdata/presets.xml"))
mux.HandleFunc("/recents", serveFixture("testdata/recents.xml"))
}
func serveFixture(path string) http.HandlerFunc {
body, err := fixtures.ReadFile(path)
if err != nil {
// Embed failure is a build-time programmer error; surface it
// loudly the first time the route is hit.
return func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "fakespeaker: missing fixture "+path+": "+err.Error(), http.StatusInternalServerError)
}
}
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write(body)
}
}
@@ -0,0 +1,64 @@
package fakespeaker
import (
"context"
"encoding/xml"
"io"
"net/http"
"testing"
"time"
)
func TestFakeSpeakerServesFixtures(t *testing.T) {
s, err := Start(Config{})
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
cases := []struct {
path string
root string
}{
{"/info", "info"},
{"/presets", "presets"},
{"/recents", "recents"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
resp, err := http.Get("http://" + s.HTTPAddr() + tc.path) //nolint:noctx
if err != nil {
t.Fatalf("get %s: %v", tc.path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
var root struct {
XMLName xml.Name
}
if err := xml.Unmarshal(body, &root); err != nil {
t.Fatalf("parse XML: %v\n%s", err, body)
}
if root.XMLName.Local != tc.root {
t.Fatalf("root element = %q, want %q", root.XMLName.Local, tc.root)
}
})
}
}
+155
View File
@@ -0,0 +1,155 @@
package fakespeaker
import (
"bufio"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
)
// telnetBanner mimics what a real SoundTouch device emits on connect to
// :17000. The exact wording is not load-bearing for the migration UI —
// only TelnetReachable is — but a non-empty banner matches the production
// shape and gets surfaced in the wizard for diagnostic value.
const telnetBanner = "Welcome to the Bose SoundTouch diagnostic shell\r\n"
// telnetGetpdoResponse simulates the protobuf-text-like reply to
// `getpdo CurrentSystemConfiguration` for an *unmigrated* speaker — every
// URL still points at the Bose cloud. This is the happy path for a
// documentation screenshot: the wizard renders as "Not Migrated", lists
// the original URLs, and offers the migration plan.
//
// The shape matches what preflight_crosscheck.parseGetpdoConfig expects:
// "<key> {\n text: \"<value>\"\n}".
const telnetGetpdoResponse = `margeServerUrl {
text: "https://streaming.bose.com"
}
statsServerUrl {
text: "https://stats.bose.com"
}
swUpdateUrl {
text: "https://worldwide.bose.com/updates/soundtouch"
}
bmxRegistryUrl {
text: "https://bmxservice.bose.com/bmx/registry/v1/services"
}
->OK
`
// telnetServer is a minimal TCP server that satisfies the read-only pre-flight
// probe in pkg/service/setup/telnet_preflight.go. It handles only the commands
// the wizard actually issues and answers every other line with a stub.
type telnetServer struct {
ln net.Listener
addr string
wg sync.WaitGroup
once sync.Once
done chan struct{}
}
func startTelnetServer(listen string) (*telnetServer, error) {
ln, err := net.Listen("tcp", listen)
if err != nil {
return nil, fmt.Errorf("fakespeaker telnet: listen %s: %w", listen, err)
}
s := &telnetServer{
ln: ln,
addr: ln.Addr().String(),
done: make(chan struct{}),
}
s.wg.Add(1)
go s.accept()
return s, nil
}
func (s *telnetServer) Addr() string {
return s.addr
}
func (s *telnetServer) Stop() {
s.once.Do(func() {
close(s.done)
_ = s.ln.Close()
})
s.wg.Wait()
}
func (s *telnetServer) accept() {
defer s.wg.Done()
for {
conn, err := s.ln.Accept()
if err != nil {
// Listener closed → graceful shutdown; any other error means
// the OS gave up on us and we should also stop.
if errors.Is(err, net.ErrClosed) {
return
}
select {
case <-s.done:
return
default:
}
continue
}
s.wg.Add(1)
go s.handle(conn)
}
}
func (s *telnetServer) handle(conn net.Conn) {
defer s.wg.Done()
defer func() { _ = conn.Close() }()
// Banner on connect — clients read it via Probe() before any command.
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, err := conn.Write([]byte(telnetBanner)); err != nil {
return
}
reader := bufio.NewReader(conn)
for {
// No idle deadline — let the client drive the cadence. The client
// closes the socket after it has its answer (~600 ms idle window),
// which surfaces here as io.EOF and ends the loop.
line, err := reader.ReadString('\n')
if err != nil {
return
}
resp := respondTo(strings.TrimRight(line, "\r\n"))
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
if _, werr := conn.Write([]byte(resp)); werr != nil {
return
}
}
}
func respondTo(cmd string) string {
cmd = strings.TrimSpace(cmd)
switch cmd {
case "getpdo CurrentSystemConfiguration":
return telnetGetpdoResponse
case "":
return "->OK\r\n"
default:
// Unrecognized commands get a benign acknowledgement so the
// probe loop never hangs waiting for a response.
return "->OK\r\n"
}
}
@@ -0,0 +1,81 @@
package fakespeaker
import (
"bufio"
"context"
"net"
"strings"
"testing"
"time"
)
func TestTelnetServerBannerAndGetpdo(t *testing.T) {
s, err := Start(Config{TelnetListen: "127.0.0.1:0"})
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
if s.TelnetAddr() == "" {
t.Fatalf("telnet listener not started")
}
conn, err := net.DialTimeout("tcp", s.TelnetAddr(), 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
reader := bufio.NewReader(conn)
banner, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read banner: %v", err)
}
if !strings.Contains(banner, "Bose SoundTouch") {
t.Errorf("banner = %q, want substring %q", banner, "Bose SoundTouch")
}
if _, err := conn.Write([]byte("getpdo CurrentSystemConfiguration\r\n")); err != nil {
t.Fatalf("write: %v", err)
}
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 1024)
var got strings.Builder
for {
n, err := conn.Read(buf)
if n > 0 {
got.Write(buf[:n])
}
if err != nil {
break
}
if strings.Contains(got.String(), "->OK") {
break
}
}
out := got.String()
for _, want := range []string{"margeServerUrl", "streaming.bose.com", "swUpdateUrl"} {
if !strings.Contains(out, want) {
t.Errorf("response missing %q\nfull response:\n%s", want, out)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<info deviceID="DEADBEEFCAFE">
<name>Demo SoundTouch</name>
<type>SoundTouch 10</type>
<margeAccountUUID>0000000</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00</softwareVersion>
<serialNumber>SN0000000000000000DEMO</serialNumber>
</component>
<component>
<componentCategory>PackagedProduct</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.demo.2026-01-01T00:00:00</softwareVersion>
<serialNumber>000000P00000000DEMO</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>02:00:00:00:00:01</macAddress>
<ipAddress>127.0.0.1</ipAddress>
</networkInfo>
<networkInfo type="SMSC">
<macAddress>02:00:00:00:00:01</macAddress>
<ipAddress>127.0.0.1</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
<variant>rhino</variant>
<variantMode>normal</variantMode>
<countryCode>GB</countryCode>
<regionCode>GB</regionCode>
</info>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<presets>
<preset id="1" createdOn="1700000000" updatedOn="1700000000">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s24939" sourceAccount="" isPresetable="true">
<itemName>Demo Radio</itemName>
<containerArt>https://example.invalid/preset1.jpg</containerArt>
</ContentItem>
</preset>
<preset id="2" createdOn="1700000000" updatedOn="1700000000">
<ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s00000" sourceAccount="" isPresetable="true">
<itemName>Demo News</itemName>
<containerArt>https://example.invalid/preset2.jpg</containerArt>
</ContentItem>
</preset>
</presets>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" ?>
<recents/>