v0.2.0 80c61e3

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

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