1package daemon
2
3import (
4 "testing"
5
6 "github.com/bnema/vev/internal/protocol"
7 "github.com/bnema/vev/internal/usecase/command"
8 "github.com/stretchr/testify/require"
9)
10
11func TestAttachmentStatusUsesClientRouteSnapshot(t *testing.T) {
12 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
13 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
14 Generation: 2,
15 Active: protocol.RouteRef{Key: 3, Generation: 2},
16 Entries: []protocol.RecentRouteEntry{
17 {Key: 2, Generation: 1, Target: testRouteTarget("logs", 2), Name: "logs", HostLabel: "user@edge", Kind: protocol.RouteKindRemote, Visited: true},
18 testRouteEntry(1, 1, "work", 1, protocol.RouteKindLocal),
19 },
20 })
21
22 state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
23
24 require.Len(t, state.mru, 2)
25 require.Equal(t, []string{"logs@edge", "work"}, []string{state.mru[0].name, state.mru[1].name})
26}
27
28func TestAttachmentStatusUsesClientPublishedAttention(t *testing.T) {
29 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
30 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{
31 Generation: 2,
32 Active: protocol.RouteRef{Key: 3, Generation: 2},
33 Entries: []protocol.RecentRouteEntry{{
34 Key: 2, Generation: 1, Target: testRouteTarget("local", 9), Name: "local", Kind: protocol.RouteKindLocal, Attention: true, Visited: true,
35 }},
36 })
37
38 state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
39
40 require.Len(t, state.mru, 1)
41 require.True(t, state.mru[0].attention)
42}
43
44func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
45 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
46 token := sess.captureAttachmentCapability(ac, ac.transport())
47 ac.installTestAttachmentCapability(token)
48 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
49
50 require.False(t, d.handleAttachmentClientFrame(token, mustClientEnvelope(protocol.RecentRouteSnapshot{Generation: 2})))
51
52 awaitFrame(t, sends, "Output")
53}
54
55func TestAttachmentRouteSnapshotCopiesPublishedEntries(t *testing.T) {
56 _, _, ac, _ := newManualSessionWithPTYs(t, nil)
57 entries := []protocol.RecentRouteEntry{testRouteEntry(1, 2, "before", 1, protocol.RouteKindLocal)}
58 snapshot := protocol.RecentRouteSnapshot{Generation: 3, Entries: entries}
59 ac.setRouteSnapshot(snapshot)
60
61 entries[0].Name = "after"
62 got := ac.routeSnapshotCopy()
63 require.Equal(t, "before", got.Entries[0].Name)
64 got.Entries[0].Name = "mutated copy"
65 require.Equal(t, "before", ac.routeSnapshotCopy().Entries[0].Name)
66}
67
68func TestPaletteJumpRecentSessionTargetsHistoryRank(t *testing.T) {
69 unvisited := testRouteEntry(5, 1, "other", 5, protocol.RouteKindLocal)
70 unvisited.Visited = false
71 ringing := testRouteEntry(6, 1, "loud", 6, protocol.RouteKindLocal)
72 ringing.Visited, ringing.Attention, ringing.AttentionSeq = false, true, 1
73 quiet := protocol.RecentRouteSnapshot{
74 Generation: 4,
75 Entries: []protocol.RecentRouteEntry{unvisited, testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote)},
76 }
77 rung := protocol.RecentRouteSnapshot{
78 Generation: 4,
79 Entries: []protocol.RecentRouteEntry{testRouteEntry(2, 3, "remote", 2, protocol.RouteKindRemote), unvisited, ringing},
80 }
81 tests := []struct {
82 name string
83 snapshot protocol.RecentRouteSnapshot
84 rank int
85 wantKey uint64
86 wantGen uint64
87 wantErr error
88 }{
89 {name: "rank 1 skips the unvisited entry", snapshot: quiet, rank: 1, wantKey: 2, wantGen: 3},
90 {name: "rank beyond the history is refused", snapshot: quiet, rank: 2, wantErr: command.ErrInvalidArguments},
91 {name: "rank after the visited prefix reaches a ringing entry", snapshot: rung, rank: 2, wantKey: 6, wantGen: 1},
92 }
93 for _, tt := range tests {
94 t.Run(tt.name, func(t *testing.T) {
95 d, source, ac, _ := newManualSessionWithPTYs(t, nil)
96 transport := &closeTrackingTransport{}
97 ac.replaceTransport(transport)
98 rc := d.attachCoordinator(source, nil, ac, true)
99 token := source.captureAttachmentCapability(ac, transport)
100 token.lease = rc.attachmentLease(ac)
101 ac.installTestAttachmentCapability(token)
102 effect, admitted := ac.beginAttachmentEffect(token)
103 require.True(t, admitted)
104 defer effect.End()
105
106 exec := paletteExec{d: d, sess: source, attachment: source, ac: ac, routeSnapshot: tt.snapshot, effect: effect}
107 err := exec.JumpRecentSession(tt.rank)
108 if tt.wantErr != nil {
109 require.ErrorIs(t, err, tt.wantErr)
110 return
111 }
112 require.NoError(t, err)
113
114 var action protocol.RouteNavigationAction
115 found := false
116 for _, frame := range transport.Sends() {
117 if decoded, ok := decodeServerMessage(t, frame).(protocol.RouteNavigationAction); ok {
118 action, found = decoded, true
119 }
120 }
121 require.True(t, found)
122 require.Equal(t, protocol.RouteNavigationAction{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}, action)
123 hints := recentRouteHints(tt.snapshot, nil)
124 require.Equal(t, action.Key, hints.Recent[tt.rank-1].Key, "hint and jump share the rank mapping")
125 })
126 }
127}
128
129func TestRecentRouteHintsRetainSnapshotSelectionIdentity(t *testing.T) {
130 snapshot := protocol.RecentRouteSnapshot{
131 Generation: 8,
132 Entries: []protocol.RecentRouteEntry{{
133 Key: 11, Generation: 7, Target: testRouteTarget("logs", 11), Name: "logs", HostLabel: "edge", Kind: protocol.RouteKindRemote, Visited: true,
134 }},
135 }
136
137 hints := recentRouteHints(snapshot, nil)
138
139 require.Len(t, hints.Recent, 1)
140 require.Equal(t, "logs@edge", hints.Recent[0].Name)
141 require.Equal(t, uint64(8), hints.Recent[0].SnapshotGeneration)
142 require.Equal(t, uint64(11), hints.Recent[0].Key)
143 require.Equal(t, uint64(7), hints.Recent[0].Generation)
144}
145
146func TestStatusHistoryShowsVisitedAndRingingRoutes(t *testing.T) {
147 unvisited := testRouteEntry(4, 1, "other", 4, protocol.RouteKindLocal)
148 unvisited.Visited = false
149 ringingLocal := testRouteEntry(5, 1, "build", 5, protocol.RouteKindLocal)
150 ringingLocal.Visited, ringingLocal.Attention, ringingLocal.AttentionSeq = false, true, 1
151 ringingRemote := testRouteEntry(6, 1, "deploy", 6, protocol.RouteKindRemote)
152 ringingRemote.Visited, ringingRemote.Attention, ringingRemote.AttentionSeq = false, true, 2
153 tests := []struct {
154 name string
155 entries []protocol.RecentRouteEntry
156 want []string
157 }{
158 {name: "fresh client shows no history", entries: []protocol.RecentRouteEntry{unvisited}, want: nil},
159 {name: "visited routes keep their order", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, testRouteEntry(3, 1, "logs", 3, protocol.RouteKindLocal)}, want: []string{"work", "logs"}},
160 {name: "fresh client still shows ringing local and remote routes", entries: []protocol.RecentRouteEntry{unvisited, ringingLocal, ringingRemote}, want: []string{"build", "deploy@remote"}},
161 {name: "ringing routes follow visited ones", entries: []protocol.RecentRouteEntry{testRouteEntry(2, 1, "work", 2, protocol.RouteKindLocal), unvisited, ringingLocal}, want: []string{"work", "build"}},
162 }
163 for _, tt := range tests {
164 t.Run(tt.name, func(t *testing.T) {
165 d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
166 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 2, Entries: tt.entries})
167
168 state := d.barStateForAttachmentPaletteHintsFor(sess, ac, "", nil, protocol.RecentRouteSnapshot{})
169
170 var names []string
171 for _, entry := range state.mru {
172 names = append(names, entry.name)
173 if entry.name == "build" || entry.name == "deploy@remote" {
174 require.True(t, entry.attention, "%s must draw its bell", entry.name)
175 }
176 }
177 require.Equal(t, tt.want, names)
178 require.Equal(t, len(tt.want), len(recentRouteHints(ac.routeSnapshotCopy(), nil).Recent))
179 })
180 }
181}
182
183func TestCtrlDigitSwitchesToRecentHistoryRank(t *testing.T) {
184 snapshot := protocol.RecentRouteSnapshot{
185 Generation: 4,
186 Entries: []protocol.RecentRouteEntry{
187 testRouteEntry(2, 3, "first", 2, protocol.RouteKindLocal),
188 testRouteEntry(7, 1, "second", 7, protocol.RouteKindRemote),
189 },
190 }
191 tests := []struct {
192 name string
193 input string
194 wantKey uint64
195 wantGen uint64
196 none bool
197 legacy bool
198 }{
199 {name: "ctrl 1 previous session", input: "\x1b[49;5u", wantKey: 2, wantGen: 3},
200 {name: "ctrl 2 second previous", input: "\x1b[50;5u", wantKey: 7, wantGen: 1},
201 {name: "azerty ctrl 2 base key", input: "\x1b[233::50;5u", wantKey: 7, wantGen: 1},
202 {name: "ctrl 3 beyond history is a no-op", input: "\x1b[51;5u", none: true},
203 {name: "legacy client does not intercept", input: "\x1b[49;5u", none: true, legacy: true},
204 }
205 for _, tt := range tests {
206 t.Run(tt.name, func(t *testing.T) {
207 d, source, ac, _ := newManualSessionWithPTYs(t, nil)
208 transport := &closeTrackingTransport{}
209 ac.replaceTransport(transport)
210 rc := d.attachCoordinator(source, nil, ac, true)
211 token := source.captureAttachmentCapability(ac, transport)
212 token.lease = rc.attachmentLease(ac)
213 ac.installTestAttachmentCapability(token)
214 ac.setRouteSnapshot(snapshot)
215 ac.keys.SetKittyKeyboard(!tt.legacy)
216 effect, admitted := ac.beginAttachmentEffect(token)
217 require.True(t, admitted)
218 defer effect.End()
219
220 d.handleInputForAttachment(effect, []byte(tt.input))
221
222 var actions []protocol.RouteNavigationAction
223 for _, frame := range transport.Sends() {
224 if decoded, ok := decodeServerMessage(t, frame).(protocol.RouteNavigationAction); ok {
225 actions = append(actions, decoded)
226 }
227 }
228 if tt.none {
229 require.Empty(t, actions)
230 return
231 }
232 require.Equal(t, []protocol.RouteNavigationAction{{SnapshotGeneration: 4, Key: tt.wantKey, Generation: tt.wantGen}}, actions)
233 })
234 }
235}