v0.3.0 99dc266

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

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