1package sessionwire
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/protocol/wire"
11 "github.com/stretchr/testify/require"
12)
13
14func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
15 target := testTarget()
16 exact := protocol.ExactSessionTarget{LifecycleID: target.LifecycleID, SessionName: target.SessionName}
17 tests := []struct {
18 name string
19 message protocol.ClientMessage
20 }{
21 {name: "hello", message: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}},
22 {name: "input", message: protocol.Input{InputSeq: 1, Data: []byte("x")}},
23 {name: "resize", message: protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}},
24 {name: "detach", message: protocol.Detach{}},
25 {name: "closed detach", message: protocol.Detach{Closed: true}},
26 {name: "ping", message: protocol.Ping{}},
27 {name: "list", message: protocol.List{}},
28 {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}},
29 {name: "theme", message: protocol.Theme{TrueColor: true}},
30 {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}},
31 {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}},
32 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
33 {name: "command", message: protocol.CommandRequest{Version: protocol.Version, RequestID: 1, Slug: "list-sessions"}},
34 {name: "reset", message: protocol.OutputResetRequest{}},
35 {name: "UI fence", message: protocol.UIFence{ActionID: 7}},
36 {name: "UI fence pointer", message: &protocol.UIFence{ActionID: 7}},
37 {name: "select tab", message: protocol.SelectTab{TabID: "tab-2"}},
38 {name: "preview", message: protocol.RemotePreviewWatch{Request: protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 1}, MinInterval: 33 * time.Millisecond}},
39 {name: "attention", message: protocol.RouteAttentionSubscription{}},
40 {name: "same peer", message: protocol.SamePeerSwitchRequest{RequestID: 1, Target: exact}},
41 {name: "snapshot", message: protocol.RecentRouteSnapshot{}},
42 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
43 {name: "creation failure", message: protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
44 {name: "creation failure pointer", message: &protocol.SessionCreationFailure{RequestID: 1, Code: protocol.RouteFailureUnavailable}},
45 }
46 for _, tt := range tests {
47 t.Run(tt.name, func(t *testing.T) {
48 raw := &scriptedTransport{recv: []wire.Envelope{mustPreambleResponse(t)}}
49 require.NoError(t, NewClientConnection(raw).SendClient(tt.message))
50 got, err := DecodeClientEnvelope(mustSingleAppPayload(t, raw))
51 require.NoError(t, err)
52 switch message := tt.message.(type) {
53 case *protocol.SessionCreationFailure:
54 require.Equal(t, *message, got)
55 case *protocol.UIFence:
56 require.Equal(t, *message, got)
57 default:
58 require.Equal(t, tt.message, got)
59 }
60 })
61 }
62}
63
64func TestClientConnectionDecodesEveryServerMessage(t *testing.T) {
65 exact := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}
66 tests := []struct {
67 name string
68 message protocol.ServerMessage
69 }{
70 {name: "welcome", message: protocol.Welcome{SessionID: "session"}},
71 {name: "error", message: protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"}},
72 {name: "output", message: protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Context: testUIOutputContext()}},
73 {name: "detached", message: protocol.Detached{Reason: protocol.ReasonDetach}},
74 {name: "pong", message: protocol.Pong{}},
75 {name: "sessions", message: protocol.Sessions{}},
76 {name: "command result", message: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}},
77 {name: "kill result", message: protocol.KillResult{RequestID: 1, Outcome: protocol.KillSucceeded}},
78 {name: "kill result partial failures", message: protocol.KillResult{RequestID: 2, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "partial", Failures: []protocol.KillFailure{{Class: "stopped", Name: "work", Text: "boom"}}}},
79 {name: "attach target", message: protocol.AttachTarget{Session: "work", Intent: protocol.IntentAttach}},
80 {name: "preview", message: protocol.RemotePreview{Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewUnavailable}},
81 {name: "identity", message: protocol.CommittedRouteIdentity{Target: exact}},
82 {name: "route action", message: protocol.RouteNavigationAction{SnapshotGeneration: 1, Key: 1, Generation: 1}},
83 {name: "route create", message: protocol.RouteCreateSessionAction{RequestID: 1, SnapshotGeneration: 1, Key: 1, Generation: 1, SessionName: "example"}},
84 {name: "route failure", message: protocol.RouteNavigationFailure{Key: 1, Generation: 1, Code: protocol.RouteFailureUnavailable}},
85 {name: "route position", message: protocol.RoutePosition{Target: exact, ActiveTabID: "tab-1"}},
86 {name: "switch failure", message: protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchUnavailable}},
87 }
88 for _, tt := range tests {
89 t.Run(tt.name, func(t *testing.T) {
90 raw := &scriptedTransport{recv: mustClientPreambleQueue(t, mustEncodeServer(t, tt.message))}
91 got, err := NewClientConnection(raw).ReceiveServer()
92 require.NoError(t, err)
93 require.Equal(t, tt.message, got)
94 })
95 }
96}
97
98func TestClientConnectionClassifiesFailuresAndPreservesCapabilities(t *testing.T) {
99 t.Run("wrong direction", func(t *testing.T) {
100 // A client envelope on the server-to-client path is rejected
101 // before any mutation: its fields are unknown to the server
102 // envelope, so decoding reports the wrong-direction category.
103 raw := &scriptedTransport{recv: mustClientPreambleQueue(t, mustEncodeClient(t, protocol.Input{InputSeq: 1, Data: []byte("x")}))}
104 _, err := NewClientConnection(raw).ReceiveServer()
105 var failure *protocol.DecodeFailure
106 require.ErrorAs(t, err, &failure)
107 require.Equal(t, protocol.DecodeUnknownType, failure.Category)
108 })
109
110 t.Run("truncated payload", func(t *testing.T) {
111 valid := mustEncodeServer(t, protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"})
112 raw := &scriptedTransport{recv: mustClientPreambleQueue(t, valid[:1])}
113 _, err := NewClientConnection(raw).ReceiveServer()
114 var failure *protocol.DecodeFailure
115 require.ErrorAs(t, err, &failure)
116 require.Equal(t, protocol.DecodeMalformed, failure.Category)
117 })
118
119 t.Run("trailing garbage", func(t *testing.T) {
120 valid := mustEncodeServer(t, protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "error"})
121 // A second top-level variant occurrence is trailing data, not
122 // a longer message: the scanner rejects duplicate variants.
123 duplicate := mustEncodeServer(t, protocol.Pong{})
124 raw := &scriptedTransport{recv: mustClientPreambleQueue(t, append(append([]byte(nil), valid...), duplicate...))}
125 _, err := NewClientConnection(raw).ReceiveServer()
126 var failure *protocol.DecodeFailure
127 require.ErrorAs(t, err, &failure)
128 require.Equal(t, protocol.DecodeMalformed, failure.Category)
129 require.ErrorIs(t, failure.Err, wire.ErrScanDuplicate)
130 })
131
132 t.Run("capabilities", func(t *testing.T) {
133 events := make(chan ports.LinkEvent, 1)
134 raw := &capableTransport{events: events}
135 connection := NewClientConnection(raw)
136 require.Equal(t, uint8(1), connection.Capabilities().PreferredOutputWindow)
137 require.True(t, connection.Capabilities().LinkState)
138 require.Equal(t, ports.LinkStateDegraded, connection.LinkState())
139 require.Equal(t, (<-chan ports.LinkEvent)(events), connection.LinkEvents())
140 })
141}