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>
@@ -16,12 +16,14 @@ dist/
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
/screenshots
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -336,6 +336,10 @@ docker-run-ports:
|
||||
@echo "Running Docker container with port mapping (discovery will be manual)..."
|
||||
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
screenshots:
|
||||
@echo "Capturing documentation screenshots..."
|
||||
@bash scripts/screenshots/run.sh
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@@ -356,6 +360,7 @@ help:
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
|
||||
// optionally registers it with a running soundtouch-service so the web UI
|
||||
// has a device to display.
|
||||
//
|
||||
// Intended for documentation screenshots and local UI smoke checks. Do not
|
||||
// use against a real network — the fixture payload is synthetic and would
|
||||
// confuse other tooling that expects live device data.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dummy-speaker --port 8090 --register http://localhost:8000
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
|
||||
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
|
||||
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
|
||||
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
HTTPListen: *listen,
|
||||
TelnetListen: *telnetListen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
target := *registerAs
|
||||
if target == "" {
|
||||
target = s.HTTPAddr()
|
||||
}
|
||||
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
}
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Printf("shutting down")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
log.Printf("stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerWithService(serviceURL, deviceAddr string) error {
|
||||
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("service responded %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 544 KiB After Width: | Height: | Size: 512 KiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 95 KiB |
@@ -3,6 +3,7 @@ module github.com/gesellix/bose-soundtouch
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/chromedp/chromedp v0.15.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -18,7 +19,13 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
|
||||
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
|
||||
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
|
||||
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
|
||||
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -5,6 +11,14 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
@@ -16,9 +30,13 @@ github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdC
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
@@ -88,6 +106,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<recents/>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||