1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10)
11
12func TestPaletteSessionSelectionCompletesSamePeerSwitch(t *testing.T) {
13 d, source, ac, sends, releases := newManualTabSession(t, 1)
14 defer releaseAll(releases)
15
16 lifecycle := domain.SessionLifecycleID{7}
17 target := &session{
18 sessionCore: sessionCore{
19 id: "target", name: "target", incarnation: lifecycle,
20 attachments: make(map[*attachedClient]struct{}),
21 },
22 ctx: source.ctx, cancel: func() {},
23 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
24 }
25 publishTiledPaneOwners(target, target.tabs[0])
26 d.mu.Lock()
27 d.sessions[target.id] = target
28 d.mu.Unlock()
29 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
30
31 token := beginRecentRoutePaletteEffect(t, d, source, ac)
32 d.handleInputForAttachment(token, []byte("\x1b "))
33 awaitFrame(t, sends, "Output")
34 d.handleInputForAttachment(token, []byte("target\r"))
35 targetFrame := awaitFrame(t, sends, "AttachTarget")
36 attachTarget := decodeServerMessage(t, targetFrame).(protocol.AttachTarget)
37 require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}, attachTarget.ExactTarget)
38
39 d.switchSamePeerForAttachment(token, protocol.SamePeerSwitchRequest{
40 RequestID: 1, Target: *attachTarget.ExactTarget,
41 })
42
43 require.Same(t, target, ac.currentAttachmentSession())
44 identityFrame := awaitFrame(t, sends, "CommittedRouteIdentity")
45 identity := decodeServerMessage(t, identityFrame).(protocol.CommittedRouteIdentity)
46 require.Equal(t, attachTarget.ExactTarget, &identity.Target)
47}
48
49func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) {
50 d, source, ac, sends, releases := newManualTabSession(t, 1)
51 defer releaseAll(releases)
52
53 lifecycle := domain.SessionLifecycleID{7}
54 target := &session{
55 sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
56 ctx: source.ctx,
57 cancel: func() {},
58 tabs: []*tab{
59 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
60 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
61 },
62 }
63 for _, tab := range target.tabs {
64 publishTiledPaneOwners(target, tab)
65 }
66 d.mu.Lock()
67 d.sessions[target.id] = target
68 d.mu.Unlock()
69 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
70
71 token := source.captureAttachmentCapability(ac, ac.transport())
72 effect, admitted := ac.beginAttachmentEffect(token)
73 require.True(t, admitted)
74 defer effect.End()
75 requestTarget := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
76 ac.offerSamePeerTarget(requestTarget)
77
78 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{
79 RequestID: 1,
80 Target: requestTarget,
81 PreferredTabID: domain.TabStableID(target.tabs[1].stableID),
82 })
83
84 require.Same(t, target, ac.currentAttachmentSession())
85 require.Equal(t, domain.TabStableID(target.tabs[1].stableID), ac.viewSnapshot().tabID)
86 identityFrame := awaitFrame(t, sends, "CommittedRouteIdentity")
87 identity := decodeServerMessage(t, identityFrame).(protocol.CommittedRouteIdentity)
88 require.Equal(t, protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}, identity.Target)
89}
90
91func TestFinishSendErrorDetachClearsSamePeerOffer(t *testing.T) {
92 d, sess, ac, _, releases := newManualTabSession(t, 1)
93 defer releaseAll(releases)
94 ac.offerSamePeerTarget(protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "target"})
95
96 d.finishSendErrorDetach(sess, ac, ac.transport())
97
98 ac.samePeerOfferMu.Lock()
99 defer ac.samePeerOfferMu.Unlock()
100 require.Nil(t, ac.samePeerOffer)
101}
102
103func TestSamePeerSwitchRejectsStaleTargetWithoutMutation(t *testing.T) {
104 d, source, ac, sends, releases := newManualTabSession(t, 1)
105 defer releaseAll(releases)
106 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
107
108 token := source.captureAttachmentCapability(ac, ac.transport())
109 effect, admitted := ac.beginAttachmentEffect(token)
110 require.True(t, admitted)
111 defer effect.End()
112
113 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{
114 RequestID: 1,
115 Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{9}, SessionName: "missing"},
116 })
117
118 require.Same(t, source, ac.currentAttachmentSession())
119 failureFrame := awaitFrame(t, sends, "SamePeerSwitchFailure")
120 failure := decodeServerMessage(t, failureFrame).(protocol.SamePeerSwitchFailure)
121 require.Equal(t, protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchStaleTarget}, failure)
122}
123
124// TestSamePeerSwitchClientInitiated pins that the client picker can move its
125// attachment without a daemon offer, while a pending offer still admits only
126// its own exact target.
127func TestSamePeerSwitchClientInitiated(t *testing.T) {
128 lifecycle := domain.SessionLifecycleID{7}
129 requestTarget := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
130 otherOffer := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{8}, SessionName: "other"}
131 tests := []struct {
132 name string
133 offer *protocol.ExactSessionTarget
134 purging bool
135 switched bool
136 wantCode protocol.SamePeerSwitchFailureCode
137 }{
138 {name: "no pending offer switches", switched: true},
139 {name: "matching offer switches", offer: &requestTarget, switched: true},
140 {name: "another pending offer refuses", offer: &otherOffer, wantCode: protocol.SamePeerSwitchStaleTarget},
141 {name: "a running purge refuses", purging: true, wantCode: protocol.SamePeerSwitchUnavailable},
142 {name: "a running purge keeps the pending offer", offer: &requestTarget, purging: true, wantCode: protocol.SamePeerSwitchUnavailable},
143 }
144 for _, tt := range tests {
145 t.Run(tt.name, func(t *testing.T) {
146 d, source, ac, sends, releases := newManualTabSession(t, 1)
147 defer releaseAll(releases)
148 target := &session{
149 sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
150 ctx: source.ctx,
151 cancel: func() {},
152 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
153 }
154 publishTiledPaneOwners(target, target.tabs[0])
155 d.mu.Lock()
156 d.sessions[target.id] = target
157 d.mu.Unlock()
158 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
159 if tt.offer != nil {
160 ac.offerSamePeerTarget(*tt.offer)
161 }
162 d.mu.Lock()
163 d.purgeAdmissionClosing = tt.purging
164 d.mu.Unlock()
165
166 token := source.captureAttachmentCapability(ac, ac.transport())
167 effect, admitted := ac.beginAttachmentEffect(token)
168 require.True(t, admitted)
169 defer effect.End()
170 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
171
172 d.mu.Lock()
173 require.Zero(t, d.purgeAdmissionActive, "the switch releases its purge admission")
174 d.purgeAdmissionClosing = false
175 d.mu.Unlock()
176 if tt.switched {
177 require.Same(t, target, ac.currentAttachmentSession())
178 identity := decodeServerMessage(t, awaitFrame(t, sends, "CommittedRouteIdentity")).(protocol.CommittedRouteIdentity)
179 require.Equal(t, requestTarget, identity.Target)
180 return
181 }
182 require.Same(t, source, ac.currentAttachmentSession())
183 failure := decodeServerMessage(t, awaitFrame(t, sends, "SamePeerSwitchFailure")).(protocol.SamePeerSwitchFailure)
184 require.Equal(t, tt.wantCode, failure.Code)
185 if tt.offer != nil {
186 ac.samePeerOfferMu.Lock()
187 defer ac.samePeerOfferMu.Unlock()
188 require.Equal(t, tt.offer, ac.samePeerOffer, "a refused switch keeps the pending offer")
189 }
190 })
191 }
192}
193
194func TestSamePeerSwitchRefreshesTargetFromSwitchingAttachment(t *testing.T) {
195 targetEnv := []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/run/other-agent"}
196 tests := []struct {
197 name string
198 clientEnv []string
199 want []string
200 }{
201 {
202 name: "local attachment refreshes session-bound variables",
203 clientEnv: []string{"SHELL=/bin/bash", "WAYLAND_DISPLAY=wayland-1"},
204 want: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1"},
205 },
206 {
207 name: "daemon-owned attachment leaves the target untouched",
208 want: targetEnv,
209 },
210 }
211 for _, tt := range tests {
212 t.Run(tt.name, func(t *testing.T) {
213 d, source, ac, _, releases := newManualTabSession(t, 1)
214 defer releaseAll(releases)
215 ac.setClientEnvironment(tt.clientEnv)
216
217 lifecycle := domain.SessionLifecycleID{7}
218 target := &session{
219 sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
220 ctx: source.ctx,
221 cancel: func() {},
222 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
223 env: append([]string(nil), targetEnv...),
224 }
225 publishTiledPaneOwners(target, target.tabs[0])
226 d.mu.Lock()
227 d.sessions[target.id] = target
228 d.mu.Unlock()
229 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
230
231 token := source.captureAttachmentCapability(ac, ac.transport())
232 effect, admitted := ac.beginAttachmentEffect(token)
233 require.True(t, admitted)
234 defer effect.End()
235 requestTarget := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
236 ac.offerSamePeerTarget(requestTarget)
237
238 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
239
240 require.Same(t, target, ac.currentAttachmentSession())
241 target.mu.Lock()
242 defer target.mu.Unlock()
243 require.Equal(t, tt.want, target.env)
244 })
245 }
246}