navigation_attachment_handoff_test.go RawHistoryFolder

1package daemon
2
3import (
4	"sync"
5	"testing"
6	"time"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/protocol"
10	"github.com/bnema/vev/internal/usecase/layout"
11	"github.com/bnema/vev/internal/usecase/picker"
12	"github.com/stretchr/testify/require"
13)
14
15func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
16	tests := []struct {
17		name           string
18		releasedAction string
19		wantErr        bool
20		run            func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error
21		check          func(*testing.T, *Daemon, *session, *session)
22	}{
23		{
24			name: "palette session", releasedAction: "detach",
25			run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
26				return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
27			},
28		},
29		{
30			name: "session overflow", releasedAction: "detach",
31			run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
32				source.mu.Lock()
33				source.name = "alpha"
34				source.mu.Unlock()
35				target.mu.Lock()
36				target.name = "charlie"
37				target.mu.Unlock()
38				d.ApplyConfig(domain.Config{Nav: domain.NavConfig{OverflowSessions: true}})
39				return d.focusDir(source, ac, layout.Down, effect)
40			},
41		},
42		{
43			name: "stopped session resume", releasedAction: "detach",
44			run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
45				d.mu.Lock()
46				delete(d.sessions, target.id)
47				d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: protocol.SessionDown}
48				d.mu.Unlock()
49				expectedCreatedAt := int64(9)
50				return d.switchToTargetForAttachment(effect, picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
51			},
52			check: func(t *testing.T, d *Daemon, _, _ *session) {
53				d.mu.Lock()
54				_, stopped := d.inactive["stopped"]
55				resumed := d.findByNameLocked("stopped")
56				d.mu.Unlock()
57				require.True(t, stopped)
58				require.Nil(t, resumed)
59			},
60		},
61		{
62			name: "transition prompt creation", releasedAction: "create-session", wantErr: true,
63			run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffect) error {
64				return d.createSessionAndSwitchForAttachment(effect, "created")
65			},
66			check: func(t *testing.T, d *Daemon, _, _ *session) {
67				d.mu.Lock()
68				created := d.findByNameLocked("created")
69				d.mu.Unlock()
70				require.Nil(t, created, "stale prompt submission created a target session")
71			},
72		},
73	}
74
75	for _, tt := range tests {
76		t.Run(tt.name, func(t *testing.T) {
77			d, source, old, _, releases := newManualTabSession(t, 2)
78			defer releaseAll(releases)
79			d.ptys = newFactorySeq(t)
80			target := &session{sessionCore: sessionCore{id: "target", name: "target", incarnation: domain.SessionLifecycleID{1}}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
81				newTab(nil, domain.Size{Cols: 80, Rows: 23}),
82				newTab(nil, domain.Size{Cols: 80, Rows: 23}),
83			}}
84			d.mu.Lock()
85			d.sessions[target.id] = target
86			d.mu.Unlock()
87
88			rc := d.attachCoordinator(source, nil, old, true)
89			token := source.captureAttachmentCapability(old, old.transport())
90			token.lease = rc.attachmentLease(old)
91			old.installTestAttachmentCapability(token)
92			effect, admitted := old.beginAttachmentEffect(token)
93			require.True(t, admitted)
94
95			admissionEnded := make(chan struct{})
96			releaseAction := make(chan struct{})
97			var admissionEndedOnce sync.Once
98			d.afterActionAttachmentEffectEnded = func(action string) {
99				if action == tt.releasedAction {
100					admissionEndedOnce.Do(func() { close(admissionEnded) })
101					<-releaseAction
102				}
103			}
104			actionDone := make(chan error, 1)
105			go func() { actionDone <- tt.run(d, source, target, old, effect) }()
106			select {
107			case <-admissionEnded:
108			case <-time.After(time.Second):
109				t.Fatal("handoff did not release its role ticket")
110			}
111
112			replacement, err := d.transitionAttachment(attachmentTransitionRequest{
113				source: source, target: source, next: old,
114
115				expectedTransport: old.transportSnapshot(), ready: true,
116			})
117			require.NoError(t, err)
118			close(releaseAction)
119			actionErr := <-actionDone
120			if tt.wantErr {
121				require.Error(t, actionErr)
122			} else {
123				require.NoError(t, actionErr)
124			}
125
126			require.Same(t, source, old.currentSession())
127			source.mu.Lock()
128			require.Contains(t, source.snapshotAttachmentsLocked(), old)
129			source.mu.Unlock()
130			target.mu.Lock()
131			require.Empty(t, target.snapshotAttachmentsLocked(), "stale handoff attached to the target")
132			require.Zero(t, testAttachmentTabIndexLocked(target), "stale handoff changed target tab focus")
133			target.mu.Unlock()
134			if tt.check != nil {
135				tt.check(t, d, source, target)
136			}
137			d.deferAttachmentTransitionCleanups(replacement)
138		})
139	}
140}
141
142func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.T) {
143	d, source, old, _ := newManualSessionWithPTYs(t, nil)
144	d.mu.Lock()
145	d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 7, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
146	d.mu.Unlock()
147
148	rc := d.attachCoordinator(source, nil, old, true)
149	token := source.captureAttachmentCapability(old, old.transport())
150	token.lease = rc.attachmentLease(old)
151	old.installTestAttachmentCapability(token)
152	effect, admitted := old.beginAttachmentEffect(token)
153	require.True(t, admitted)
154
155	ended := make(chan struct{})
156	release := make(chan struct{})
157	var endedOnce sync.Once
158	d.afterActionAttachmentEffectEnded = func(action string) {
159		if action == "detach" {
160			endedOnce.Do(func() { close(ended) })
161			<-release
162		}
163	}
164	expectedCreatedAt := int64(7)
165	done := make(chan error, 1)
166	go func() {
167		done <- d.switchToTargetForAttachment(effect, picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
168	}()
169	select {
170	case <-ended:
171	case <-time.After(time.Second):
172		t.Fatal("stopped-session handoff did not release its role ticket")
173	}
174
175	next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
176	next.initOverlays()
177	replacement, err := d.transitionAttachment(attachmentTransitionRequest{
178		target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
179	})
180	require.NoError(t, err)
181	close(release)
182	require.NoError(t, <-done)
183
184	d.mu.Lock()
185	_, stillStopped := d.inactive["stopped"]
186	resumed := d.findByNameLocked("stopped")
187	d.mu.Unlock()
188	require.True(t, stillStopped)
189	require.Nil(t, resumed)
190	d.deferAttachmentTransitionCleanups(replacement)
191}

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