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 switched bool
135 }{
136 {name: "no pending offer switches", switched: true},
137 {name: "matching offer switches", offer: &requestTarget, switched: true},
138 {name: "another pending offer refuses", offer: &otherOffer},
139 }
140 for _, tt := range tests {
141 t.Run(tt.name, func(t *testing.T) {
142 d, source, ac, sends, releases := newManualTabSession(t, 1)
143 defer releaseAll(releases)
144 target := &session{
145 sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
146 ctx: source.ctx,
147 cancel: func() {},
148 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
149 }
150 publishTiledPaneOwners(target, target.tabs[0])
151 d.mu.Lock()
152 d.sessions[target.id] = target
153 d.mu.Unlock()
154 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
155 if tt.offer != nil {
156 ac.offerSamePeerTarget(*tt.offer)
157 }
158
159 token := source.captureAttachmentCapability(ac, ac.transport())
160 effect, admitted := ac.beginAttachmentEffect(token)
161 require.True(t, admitted)
162 defer effect.End()
163 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
164
165 if tt.switched {
166 require.Same(t, target, ac.currentAttachmentSession())
167 identity := decodeServerMessage(t, awaitFrame(t, sends, "CommittedRouteIdentity")).(protocol.CommittedRouteIdentity)
168 require.Equal(t, requestTarget, identity.Target)
169 return
170 }
171 require.Same(t, source, ac.currentAttachmentSession())
172 failure := decodeServerMessage(t, awaitFrame(t, sends, "SamePeerSwitchFailure")).(protocol.SamePeerSwitchFailure)
173 require.Equal(t, protocol.SamePeerSwitchStaleTarget, failure.Code)
174 })
175 }
176}
177
178func TestSamePeerSwitchRefreshesTargetFromSwitchingAttachment(t *testing.T) {
179 targetEnv := []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/run/other-agent"}
180 tests := []struct {
181 name string
182 clientEnv []string
183 want []string
184 }{
185 {
186 name: "local attachment refreshes session-bound variables",
187 clientEnv: []string{"SHELL=/bin/bash", "WAYLAND_DISPLAY=wayland-1"},
188 want: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1"},
189 },
190 {
191 name: "daemon-owned attachment leaves the target untouched",
192 want: targetEnv,
193 },
194 }
195 for _, tt := range tests {
196 t.Run(tt.name, func(t *testing.T) {
197 d, source, ac, _, releases := newManualTabSession(t, 1)
198 defer releaseAll(releases)
199 ac.setClientEnvironment(tt.clientEnv)
200
201 lifecycle := domain.SessionLifecycleID{7}
202 target := &session{
203 sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
204 ctx: source.ctx,
205 cancel: func() {},
206 tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
207 env: append([]string(nil), targetEnv...),
208 }
209 publishTiledPaneOwners(target, target.tabs[0])
210 d.mu.Lock()
211 d.sessions[target.id] = target
212 d.mu.Unlock()
213 ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
214
215 token := source.captureAttachmentCapability(ac, ac.transport())
216 effect, admitted := ac.beginAttachmentEffect(token)
217 require.True(t, admitted)
218 defer effect.End()
219 requestTarget := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
220 ac.offerSamePeerTarget(requestTarget)
221
222 d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
223
224 require.Same(t, target, ac.currentAttachmentSession())
225 target.mu.Lock()
226 defer target.mu.Unlock()
227 require.Equal(t, tt.want, target.env)
228 })
229 }
230}