🎉 live server seems to be working now
This commit is contained in:
773
third_party/sendspin-go/pkg/protocol/client_test.go
vendored
Normal file
773
third_party/sendspin-go/pkg/protocol/client_test.go
vendored
Normal file
@@ -0,0 +1,773 @@
|
||||
// ABOUTME: Tests for WebSocket client implementation
|
||||
// ABOUTME: Tests connection, handshake, and message routing
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
config := Config{
|
||||
ServerAddr: "localhost:8927",
|
||||
ClientID: "test-client",
|
||||
Name: "Test Player",
|
||||
}
|
||||
|
||||
client := NewClient(config)
|
||||
if client == nil {
|
||||
t.Fatal("expected client to be created")
|
||||
}
|
||||
|
||||
if client.config.ServerAddr != "localhost:8927" {
|
||||
t.Errorf("expected server addr localhost:8927, got %s", client.config.ServerAddr)
|
||||
}
|
||||
|
||||
if client.ArtworkChunks == nil {
|
||||
t.Error("expected ArtworkChunks channel to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// buildBinaryFrame constructs a binary protocol frame matching what the server
|
||||
// emits: [1 byte type][8 byte big-endian timestamp µs][payload].
|
||||
func buildBinaryFrame(msgType byte, timestamp int64, payload []byte) []byte {
|
||||
frame := make([]byte, BinaryMessageHeaderSize+len(payload))
|
||||
frame[0] = msgType
|
||||
binary.BigEndian.PutUint64(frame[1:BinaryMessageHeaderSize], uint64(timestamp))
|
||||
copy(frame[BinaryMessageHeaderSize:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
// recvWithTimeout waits briefly for a value on a channel. Returns the zero
|
||||
// value + false if nothing arrives in time.
|
||||
func recvAudioChunk(t *testing.T, ch <-chan AudioChunk) (AudioChunk, bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case chunk := <-ch:
|
||||
return chunk, true
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
return AudioChunk{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func recvArtworkChunk(t *testing.T, ch <-chan ArtworkChunk) (ArtworkChunk, bool) {
|
||||
t.Helper()
|
||||
select {
|
||||
case chunk := <-ch:
|
||||
return chunk, true
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
return ArtworkChunk{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBinaryMessage_AudioChunkRouting(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
payload := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
|
||||
client.handleBinaryMessage(buildBinaryFrame(AudioChunkMessageType, 123_456, payload))
|
||||
|
||||
chunk, ok := recvAudioChunk(t, client.AudioChunks)
|
||||
if !ok {
|
||||
t.Fatal("expected an AudioChunk on the channel")
|
||||
}
|
||||
if chunk.Timestamp != 123_456 {
|
||||
t.Errorf("timestamp = %d, want 123456", chunk.Timestamp)
|
||||
}
|
||||
if string(chunk.Data) != string(payload) {
|
||||
t.Errorf("data = %x, want %x", chunk.Data, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleBinaryMessage_ArtworkRouting covers all four artwork channel IDs
|
||||
// (types 8, 9, 10, 11 → channels 0, 1, 2, 3). Closes #27: "Unknown binary
|
||||
// message type: 8" was the spec-defined ArtworkChannel0MessageType being
|
||||
// silently dropped.
|
||||
func TestHandleBinaryMessage_ArtworkRouting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msgType byte
|
||||
channel int
|
||||
}{
|
||||
{"channel 0", ArtworkChannel0MessageType, 0},
|
||||
{"channel 1", ArtworkChannel1MessageType, 1},
|
||||
{"channel 2", ArtworkChannel2MessageType, 2},
|
||||
{"channel 3", ArtworkChannel3MessageType, 3},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
imageBytes := []byte("fake-jpeg-bytes-here")
|
||||
timestamp := int64(999_888_777)
|
||||
|
||||
client.handleBinaryMessage(buildBinaryFrame(tc.msgType, timestamp, imageBytes))
|
||||
|
||||
chunk, ok := recvArtworkChunk(t, client.ArtworkChunks)
|
||||
if !ok {
|
||||
t.Fatalf("expected an ArtworkChunk on the channel (msgType=%d)", tc.msgType)
|
||||
}
|
||||
if chunk.Channel != tc.channel {
|
||||
t.Errorf("channel = %d, want %d", chunk.Channel, tc.channel)
|
||||
}
|
||||
if chunk.Timestamp != timestamp {
|
||||
t.Errorf("timestamp = %d, want %d", chunk.Timestamp, timestamp)
|
||||
}
|
||||
if string(chunk.Data) != string(imageBytes) {
|
||||
t.Errorf("data = %q, want %q", chunk.Data, imageBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleBinaryMessage_UnknownTypeLogged confirms unknown types are still
|
||||
// dropped (and not routed anywhere) after the artwork additions. This guards
|
||||
// the switch-default branch so a future add-without-test doesn't turn a
|
||||
// real bug into silent mis-routing.
|
||||
func TestHandleBinaryMessage_UnknownTypeLogged(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
|
||||
// Type 99 is not defined anywhere in the spec.
|
||||
client.handleBinaryMessage(buildBinaryFrame(99, 0, []byte{0xff}))
|
||||
|
||||
if _, ok := recvAudioChunk(t, client.AudioChunks); ok {
|
||||
t.Error("unknown type was routed to AudioChunks")
|
||||
}
|
||||
if _, ok := recvArtworkChunk(t, client.ArtworkChunks); ok {
|
||||
t.Error("unknown type was routed to ArtworkChunks")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSupportedRoles_Default covers the old auto-built path. Keep this
|
||||
// test lean; a table test for every permutation of the four V1 support
|
||||
// fields is over-investment for what's essentially a handful of if
|
||||
// statements.
|
||||
func TestBuildSupportedRoles_Default(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config Config
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "bare default is player+metadata+controller",
|
||||
config: Config{},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1"},
|
||||
},
|
||||
{
|
||||
name: "artwork support adds artwork@v1",
|
||||
config: Config{ArtworkV1Support: &ArtworkV1Support{}},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1"},
|
||||
},
|
||||
{
|
||||
name: "visualizer support adds visualizer@v1",
|
||||
config: Config{VisualizerV1Support: &VisualizerV1Support{}},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1", "visualizer@v1"},
|
||||
},
|
||||
{
|
||||
name: "both support structs set",
|
||||
config: Config{
|
||||
ArtworkV1Support: &ArtworkV1Support{},
|
||||
VisualizerV1Support: &VisualizerV1Support{},
|
||||
},
|
||||
want: []string{"player@v1", "metadata@v1", "controller@v1", "artwork@v1", "visualizer@v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := NewClient(tc.config)
|
||||
got := client.buildSupportedRoles()
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("buildSupportedRoles() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSupportedRoles_ExplicitOverride verifies that Config.SupportedRoles
|
||||
// wins over the V1 support struct auto-build. This is the load-bearing
|
||||
// behavior for conformance metadata-only and controller scenarios that
|
||||
// need to NOT advertise player@v1.
|
||||
func TestBuildSupportedRoles_ExplicitOverride(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
config Config
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "controller only",
|
||||
config: Config{SupportedRoles: []string{"controller@v1"}},
|
||||
want: []string{"controller@v1"},
|
||||
},
|
||||
{
|
||||
name: "metadata only",
|
||||
config: Config{SupportedRoles: []string{"metadata@v1"}},
|
||||
want: []string{"metadata@v1"},
|
||||
},
|
||||
{
|
||||
// Confirms that setting ArtworkV1Support alongside an override
|
||||
// does NOT inject artwork@v1 — the caller's override is canon.
|
||||
name: "override wins over artwork support struct",
|
||||
config: Config{
|
||||
SupportedRoles: []string{"player@v1"},
|
||||
ArtworkV1Support: &ArtworkV1Support{},
|
||||
},
|
||||
want: []string{"player@v1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := NewClient(tc.config)
|
||||
got := client.buildSupportedRoles()
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("buildSupportedRoles() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewClientFromConn_HandshakeAndClose spins up a local WebSocket server
|
||||
// that plays the Sendspin handshake dance (read client/hello, send
|
||||
// server/hello, read client/state, close). The test then uses
|
||||
// NewClientFromConn + Start to drive the client side over an accepted
|
||||
// connection, proving that server-initiated scenarios can use the library
|
||||
// without hand-rolling the message loop.
|
||||
func TestNewClientFromConn_HandshakeAndClose(t *testing.T) {
|
||||
// Capture the roles the client advertises so we can assert the override
|
||||
// plumbed through to the wire.
|
||||
capturedHello := make(chan ClientHello, 1)
|
||||
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(*http.Request) bool { return true },
|
||||
}
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Read client/hello.
|
||||
_, helloBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
var envelope Message
|
||||
if err := json.Unmarshal(helloBytes, &envelope); err != nil {
|
||||
t.Errorf("unmarshal client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(envelope.Payload)
|
||||
var hello ClientHello
|
||||
_ = json.Unmarshal(payloadBytes, &hello)
|
||||
capturedHello <- hello
|
||||
|
||||
// Send server/hello.
|
||||
serverHello := Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{
|
||||
ServerID: "test-server",
|
||||
Name: "Test Server",
|
||||
Version: 1,
|
||||
ActiveRoles: hello.SupportedRoles,
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(serverHello); err != nil {
|
||||
t.Errorf("write server/hello: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Read client/state (the library sends this immediately after
|
||||
// a successful handshake — see handshake() in client.go).
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Hold the connection open briefly so the client's read loop has
|
||||
// something to read, then close.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
// Dial it ourselves to simulate a server-initiated scenario where the
|
||||
// caller has an accepted *websocket.Conn and hands it to the library.
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "test-from-conn",
|
||||
Name: "FromConn Test",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case hello := <-capturedHello:
|
||||
if !reflect.DeepEqual(hello.SupportedRoles, []string{"controller@v1"}) {
|
||||
t.Errorf("server saw SupportedRoles = %v, want [controller@v1]", hello.SupportedRoles)
|
||||
}
|
||||
if hello.ClientID != "test-from-conn" {
|
||||
t.Errorf("server saw ClientID = %q, want test-from-conn", hello.ClientID)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server never captured a client/hello")
|
||||
}
|
||||
|
||||
// ServerHello() and RawServerHello() must return the handshake data
|
||||
// after Start() completes. The test server above responds with a hello
|
||||
// whose ServerID is "test-server" and Name is "Test Server", so both
|
||||
// accessors should agree.
|
||||
parsed := client.ServerHello()
|
||||
if parsed == nil {
|
||||
t.Fatal("ServerHello() returned nil after successful handshake")
|
||||
}
|
||||
if parsed.ServerID != "test-server" {
|
||||
t.Errorf("ServerHello().ServerID = %q, want test-server", parsed.ServerID)
|
||||
}
|
||||
if parsed.Name != "Test Server" {
|
||||
t.Errorf("ServerHello().Name = %q, want Test Server", parsed.Name)
|
||||
}
|
||||
|
||||
raw := client.RawServerHello()
|
||||
if len(raw) == 0 {
|
||||
t.Fatal("RawServerHello() returned empty bytes after successful handshake")
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("RawServerHello() did not return valid JSON: %v", err)
|
||||
}
|
||||
if envelope["type"] != "server/hello" {
|
||||
t.Errorf("raw envelope type = %v, want server/hello", envelope["type"])
|
||||
}
|
||||
|
||||
// Confirm RawServerHello returns a copy: mutating the returned slice
|
||||
// must not affect subsequent calls.
|
||||
if len(raw) > 0 {
|
||||
raw[0] = 0xFF
|
||||
}
|
||||
raw2 := client.RawServerHello()
|
||||
if bytes.Equal(raw, raw2) {
|
||||
t.Error("RawServerHello returned a shared reference; mutation leaked into the client")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart_NoConnection confirms Start refuses to run without a connection
|
||||
// in place. Prevents a future caller from assuming Start does its own dialing.
|
||||
func TestStart_NoConnection(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
err := client.Start()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when Start is called with no connection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no connection") {
|
||||
t.Errorf("error message = %q, want substring %q", err.Error(), "no connection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerHello_BeforeHandshake guards against nil confusion: the
|
||||
// accessors must return nil/empty before Start() runs, not stale data
|
||||
// from a previous client or a zero-value ServerHello.
|
||||
func TestServerHello_BeforeHandshake(t *testing.T) {
|
||||
client := NewClient(Config{ServerAddr: "localhost:0", ClientID: "t", Name: "t"})
|
||||
|
||||
if hello := client.ServerHello(); hello != nil {
|
||||
t.Errorf("ServerHello() before handshake = %+v, want nil", hello)
|
||||
}
|
||||
if raw := client.RawServerHello(); raw != nil {
|
||||
t.Errorf("RawServerHello() before handshake = %v, want nil", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientSend_WritesEnvelope verifies that Client.Send emits a correctly
|
||||
// shaped {"type": ..., "payload": ...} envelope on the wire. The test
|
||||
// server reads a client/command message after handshake and asserts on the
|
||||
// envelope shape, which is exactly what the conformance adapter's
|
||||
// controller scenarios need.
|
||||
func TestClientSend_WritesEnvelope(t *testing.T) {
|
||||
capturedCommand := make(chan map[string]any, 1)
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Handshake: read client/hello, write server/hello, read client/state.
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1},
|
||||
}); err != nil {
|
||||
t.Errorf("write server/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Now read the client/command the test below will send via Send().
|
||||
_, cmdBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read client/command: %v", err)
|
||||
return
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(cmdBytes, &envelope); err != nil {
|
||||
t.Errorf("unmarshal envelope: %v", err)
|
||||
return
|
||||
}
|
||||
capturedCommand <- envelope
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "sender",
|
||||
Name: "Sender Test",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{"controller": map[string]any{"command": "next"}}
|
||||
if err := client.Send("client/command", payload); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case envelope := <-capturedCommand:
|
||||
if envelope["type"] != "client/command" {
|
||||
t.Errorf("envelope.type = %v, want client/command", envelope["type"])
|
||||
}
|
||||
innerPayload, ok := envelope["payload"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope.payload not a map: %v", envelope["payload"])
|
||||
}
|
||||
controller, ok := innerPayload["controller"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload.controller not a map: %v", innerPayload["controller"])
|
||||
}
|
||||
if controller["command"] != "next" {
|
||||
t.Errorf("controller.command = %v, want next", controller["command"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server never captured a client/command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_DetectsHalfOpenConnection verifies that the client tears down
|
||||
// (Done() fires) when the server stops responding to WebSocket control
|
||||
// pings. Without the heartbeat fix, ReadMessage blocks forever after a
|
||||
// silently-dropped connection (NAT timeout, idle eviction, missed RST) and
|
||||
// Done() never closes — the symptom Chris saw in the field as "Burst sample
|
||||
// N/8 timed out" with no reconnect.
|
||||
//
|
||||
// The fake server completes the Sendspin handshake and then installs a
|
||||
// no-op PingHandler so the client's pings are read but never elicit a Pong.
|
||||
// The client's pongWait deadline expires, ReadMessage errors out, Close
|
||||
// runs from readMessages's defer, and Done() fires.
|
||||
func TestClient_DetectsHalfOpenConnection(t *testing.T) {
|
||||
// Override the package-level heartbeat vars to test-friendly values
|
||||
// so the test wallclock budget is well under a second. Restore via
|
||||
// t.Cleanup so other tests in this binary keep production timings.
|
||||
savedPingPeriod, savedPongWait, savedWriteWait := pingPeriod, pongWait, writeWait
|
||||
pingPeriod = 50 * time.Millisecond
|
||||
pongWait = 250 * time.Millisecond
|
||||
writeWait = 100 * time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
pingPeriod = savedPingPeriod
|
||||
pongWait = savedPongWait
|
||||
writeWait = savedWriteWait
|
||||
})
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
serverDone := make(chan struct{})
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(serverDone)
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Override the default PingHandler so the server does NOT auto-pong.
|
||||
// This is the half-open simulation: pings arrive but no pong reply
|
||||
// goes back. Returning nil keeps ReadMessage from surfacing an error,
|
||||
// so the server happily reads forever while the client's read deadline
|
||||
// counts down on the other side.
|
||||
conn.SetPingHandler(func(string) error { return nil })
|
||||
|
||||
// Sendspin handshake: client/hello → server/hello → client/state.
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1},
|
||||
}); err != nil {
|
||||
t.Errorf("write server/hello: %v", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
t.Errorf("read client/state: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Drain any further frames (the client's pings) until the connection
|
||||
// errors out from the client side. We don't pong, so the client's
|
||||
// read deadline will fire and it will close the socket.
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "half-open-test",
|
||||
Name: "Half Open Test",
|
||||
Version: 1,
|
||||
SupportedRoles: []string{"controller@v1"},
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
// pongWait + slack. If Done() doesn't fire, the heartbeat is broken.
|
||||
select {
|
||||
case <-client.Done():
|
||||
case <-time.After(750 * time.Millisecond):
|
||||
t.Fatal("Done() did not fire within pongWait + slack; half-open connection went undetected")
|
||||
}
|
||||
|
||||
// Idempotent shutdown: calling Close again must not panic.
|
||||
client.Close()
|
||||
|
||||
// Let the server goroutine unwind so the test doesn't leak it.
|
||||
select {
|
||||
case <-serverDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Log("server handler did not unwind in time (not fatal; client side already verified)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandshake_PlayerSupportGatedByRoles is a regression test for a real
|
||||
// bug caught by the conformance harness against aiosendspin. The Go
|
||||
// library used to emit player@v1_support in every client/hello regardless
|
||||
// of the advertised role list. aiosendspin's schema marks
|
||||
// ClientHelloPlayerSupport.supported_formats as non-nullable, so a zero-
|
||||
// value Go PlayerV1Support (whose nil slice encodes as JSON null) caused
|
||||
// aiosendspin's mashumaro deserializer to reject the entire hello and
|
||||
// close the connection with code 1000. sendspin-go then reported
|
||||
// "handshake failed: failed to read server/hello: websocket: close 1000".
|
||||
//
|
||||
// The fix: only set hello.PlayerV1Support when player@v1 is in the
|
||||
// final advertised role list. This mirrors how ArtworkV1Support and
|
||||
// VisualizerV1Support were already being handled (only set when
|
||||
// non-nil in config), and aligns with aiosendspin's own validation
|
||||
// comment: "player@v1_support must be provided when 'player@v1' is in
|
||||
// supported_roles".
|
||||
//
|
||||
// The test captures the raw hello bytes on the wire and asserts the
|
||||
// top-level payload key is absent for non-player roles, and present
|
||||
// for player@v1 (the default).
|
||||
func TestHandshake_PlayerSupportGatedByRoles(t *testing.T) {
|
||||
// playerSupport is populated for the "advertised player" cases so the
|
||||
// emitted player@v1_support block is a well-formed one — matches what
|
||||
// a real caller would pass — and the secondary null-check assertion
|
||||
// below is meaningful rather than a false positive.
|
||||
playerSupport := PlayerV1Support{
|
||||
SupportedFormats: []AudioFormat{
|
||||
{Codec: "pcm", Channels: 2, SampleRate: 48000, BitDepth: 16},
|
||||
},
|
||||
BufferCapacity: 1_000_000,
|
||||
SupportedCommands: []string{"volume", "mute"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
supportedRoles []string
|
||||
playerV1Support PlayerV1Support
|
||||
wantPlayerKey bool
|
||||
}{
|
||||
{
|
||||
name: "controller only omits player support",
|
||||
supportedRoles: []string{"controller@v1"},
|
||||
wantPlayerKey: false,
|
||||
},
|
||||
{
|
||||
name: "metadata only omits player support",
|
||||
supportedRoles: []string{"metadata@v1"},
|
||||
wantPlayerKey: false,
|
||||
},
|
||||
{
|
||||
name: "artwork only omits player support",
|
||||
supportedRoles: []string{"artwork@v1"},
|
||||
wantPlayerKey: false,
|
||||
},
|
||||
{
|
||||
name: "player advertised keeps player support",
|
||||
supportedRoles: []string{"player@v1"},
|
||||
playerV1Support: playerSupport,
|
||||
wantPlayerKey: true,
|
||||
},
|
||||
{
|
||||
name: "player plus other roles keeps player support",
|
||||
supportedRoles: []string{"player@v1", "controller@v1"},
|
||||
playerV1Support: playerSupport,
|
||||
wantPlayerKey: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
capturedHello := make(chan []byte, 1)
|
||||
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||
handler := func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, helloBytes, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read client/hello: %v", err)
|
||||
return
|
||||
}
|
||||
// Copy the slice; gorilla reuses its underlying buffer.
|
||||
snapshot := make([]byte, len(helloBytes))
|
||||
copy(snapshot, helloBytes)
|
||||
capturedHello <- snapshot
|
||||
|
||||
// Drive the rest of the handshake so the client goroutine
|
||||
// doesn't error out on a hard close mid-stream.
|
||||
_ = conn.WriteJSON(Message{
|
||||
Type: "server/hello",
|
||||
Payload: ServerHello{ServerID: "srv", Name: "srv", Version: 1},
|
||||
})
|
||||
_, _, _ = conn.ReadMessage() // client/state
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(handler))
|
||||
defer server.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
client := NewClientFromConn(Config{
|
||||
ClientID: "test",
|
||||
Name: "Test",
|
||||
Version: 1,
|
||||
SupportedRoles: tc.supportedRoles,
|
||||
PlayerV1Support: tc.playerV1Support,
|
||||
}, conn)
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
var helloBytes []byte
|
||||
select {
|
||||
case helloBytes = <-capturedHello:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("server never captured a client/hello")
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(helloBytes, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal envelope: %v", err)
|
||||
}
|
||||
payload, ok := envelope["payload"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope.payload is not a map: %v", envelope["payload"])
|
||||
}
|
||||
|
||||
_, havePlayerKey := payload["player@v1_support"]
|
||||
if havePlayerKey != tc.wantPlayerKey {
|
||||
if tc.wantPlayerKey {
|
||||
t.Errorf("client/hello missing player@v1_support when player@v1 is advertised:\n%s", helloBytes)
|
||||
} else {
|
||||
t.Errorf("client/hello includes player@v1_support when player@v1 is NOT advertised (%v):\n%s",
|
||||
tc.supportedRoles, helloBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// When PlayerSupport is emitted and its supported_formats is nil,
|
||||
// the JSON encoder produces null. Catch that too — a nil-slice
|
||||
// null would still fail against aiosendspin's schema even if
|
||||
// we correctly gated on roles.
|
||||
if havePlayerKey {
|
||||
playerSupport, ok := payload["player@v1_support"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("player@v1_support is not a map: %v", payload["player@v1_support"])
|
||||
}
|
||||
if playerSupport["supported_formats"] == nil {
|
||||
t.Error("player@v1_support.supported_formats serialized as null; aiosendspin's schema rejects this")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user