detached ece7682

same_peer_switch_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close