move_picker_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5	"time"
6
7	"github.com/stretchr/testify/require"
8
9	"github.com/bnema/vev/internal/domain"
10	"github.com/bnema/vev/internal/ports"
11	"github.com/bnema/vev/internal/protocol"
12	"github.com/bnema/vev/internal/protocol/wire"
13)
14
15func setupMovePickerSessions(t *testing.T, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
16	t.Helper()
17	return setupMovePickerSessionsWithClock(t, stubClock{}, extraDestinationTabs)
18}
19
20func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func()) {
21	t.Helper()
22	d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, clock, extraDestinationTabs)
23	// The move suites drive typed interactions, so the daemon publishes offers
24	// and snapshots to this attachment: drain them in the background so a
25	// guarded send never blocks the fixture.
26	go func() {
27		for range sends {
28		}
29	}()
30	return d, source, ac, destination, destinationTab, releases
31}
32
33// setupMovePickerSessionsCore builds the move fixture without draining the
34// attachment transport, so a regression can read exactly what the daemon sent.
35func setupMovePickerSessionsCore(t *testing.T, clock ports.Clock, extraDestinationTabs int) (*Daemon, *session, *attachedClient, *session, *tab, []func(), chan wire.Envelope) {
36	t.Helper()
37	sourcePTY, releaseSource := newBlockingPTY(t)
38	d, source, ac, sends := newManualSessionWithPTYsClock(t, clock, sourcePTY)
39	source.id, source.name, source.incarnation = "source", "source", domain.IncarnationID{1}
40	d.mu.Lock()
41	delete(d.sessions, domain.SessionID("manual"))
42	d.sessions[source.id] = source
43	d.mu.Unlock()
44	sourceTab := source.tabs[0]
45	sourceTab.stableID = "source-tab"
46	sourcePane := sourceTab.focusedPane()
47	sourcePane.stableID = "source-pane"
48
49	releases := []func(){releaseSource}
50	destPTY, releaseDest := newBlockingPTY(t)
51	releases = append(releases, releaseDest)
52	destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
53	publishTiledPaneOwners(source, sourceTab)
54	destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}}
55	publishTiledPaneOwners(destination, destinationTab)
56	for range extraDestinationTabs {
57		extraPTY, releaseExtra := newBlockingPTY(t)
58		releases = append(releases, releaseExtra)
59		extraTab := newTabWithStableID("extra-tab", "extra-pane", extraPTY, domain.Size{Cols: 80, Rows: 23})
60		destination.tabs = append(destination.tabs, extraTab)
61		publishTiledPaneOwners(destination, extraTab)
62	}
63	d.mu.Lock()
64	d.sessions[destination.id] = destination
65	d.mu.Unlock()
66	return d, source, ac, destination, destinationTab, releases, sends
67}
68
69// moveSourceForSession names the exact source a move command captures. The
70// attachment carrying the command travels with it, exactly like the palette
71// entry: the move transaction must freeze every other effect but this one.
72func moveSourceForSession(sess *session, ac *attachedClient, tabID domain.TabStableID, paneID domain.PaneStableID) moveSourceLocator {
73	return moveSourceLocator{
74		Session:    moveSessionLocator{ID: sess.id, Incarnation: sess.incarnation, Name: sess.name},
75		TabID:      tabID,
76		PaneID:     paneID,
77		Attachment: ac,
78	}
79}
80
81// openMovePickerForTest opens one move interaction, exactly like the palette
82// command does, and releases the effect that carried the offer: the
83// interaction namespace lives on the attachment, and a later move must be able
84// to freeze every other effect on the source.
85func openMovePickerForTest(t *testing.T, d *Daemon, ac *attachedClient, sess *session, intent protocol.PickerIntent, source moveSourceLocator) {
86	t.Helper()
87	current := ac.transportSnapshot()
88	_, effect, admitted := ac.beginCurrentAttachmentEffect(sess, current.transport)
89	if !admitted {
90		// A test may deliberately drop the source attachment before opening;
91		// the captured capability still admits the open.
92		effect, admitted = ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
93	}
94	require.True(t, admitted)
95	require.NoError(t, d.openPickerForAttachment(ac, effect, intent, source, 0))
96	effect.End()
97}
98
99// pickActionEffectForTest admits one effect for a typed action. A move drains
100// every live effect on its source, so the caller ends it right before the
101// commit, exactly like a client frame that has already been consumed.
102func pickActionEffectForTest(t *testing.T, sess *session, ac *attachedClient) *attachmentEffect {
103	t.Helper()
104	effect, admitted := ac.beginAttachmentEffect(captureAttachmentCapability(sess, ac, ac.transport()))
105	require.True(t, admitted)
106	return effect
107}
108
109// moveSelectionForDestination builds the typed move the client would send for
110// the destination session's first selectable line.
111func moveSelectionForDestination(t *testing.T, ac *attachedClient, destination domain.SessionID) protocol.PickerSelection {
112	t.Helper()
113	ac.overlays.pickerMu.Lock()
114	interaction := ac.overlays.pickerInteraction
115	revision := ac.overlays.pickerRevisions[servingPickerSourceID]
116	key := ""
117	for candidate, target := range ac.overlays.pickerKeys {
118		if target.Session == destination {
119			key = candidate
120			break
121		}
122	}
123	ac.overlays.pickerMu.Unlock()
124	require.NotEmpty(t, key, "no move destination resolves to session %s", destination)
125	return protocol.PickerSelection{
126		InteractionID: interaction, SourceID: servingPickerSourceID,
127		SourceRevision: revision, Key: key, Action: protocol.PickerActionMove,
128	}
129}
130
131func TestPaletteMovePaneCapturesSourceAndOpensPicker(t *testing.T) {
132	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
133	defer releaseAll(releases)
134
135	d.handleInput(source, ac, []byte("\x1b "))
136	require.True(t, ac.overlays.paletteActive())
137	d.handleInput(source, ac, []byte("MFP\r"))
138	require.False(t, ac.overlays.paletteActive())
139
140	// The picker opens after the frame erasing the palette.
141	require.Eventually(t, ac.overlays.pickerClientActive, testWaitTimeout, time.Millisecond)
142	ac.overlays.pickerMu.Lock()
143	open := ac.overlays.pickerOpen
144	intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerMoveSource
145	ac.overlays.pickerMu.Unlock()
146	require.True(t, open)
147	require.Equal(t, protocol.PickerIntentMovePane, intent)
148	require.Equal(t, moveSessionLocator{ID: source.id, Incarnation: source.incarnation, Name: source.name}, captured.Session)
149	require.Equal(t, domain.TabStableID("source-tab"), captured.TabID)
150	require.Equal(t, domain.PaneStableID("source-pane"), captured.PaneID)
151	require.Same(t, ac, captured.Attachment)
152}
153
154func TestPaletteMoveTabCapturesActiveTabAndOpensPicker(t *testing.T) {
155	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
156	defer releaseAll(releases)
157	sourceTab := source.tabs[0]
158	sourceTab.stableID = "active-tab"
159
160	d.handleInput(source, ac, []byte("\x1b "))
161	d.handleInput(source, ac, []byte("MAT\r"))
162
163	// The picker opens after the frame erasing the palette.
164	require.Eventually(t, ac.overlays.pickerClientActive, testWaitTimeout, time.Millisecond)
165	ac.overlays.pickerMu.Lock()
166	open := ac.overlays.pickerOpen
167	intent, captured := ac.overlays.pickerIntent, ac.overlays.pickerMoveSource
168	ac.overlays.pickerMu.Unlock()
169	require.True(t, open)
170	require.Equal(t, protocol.PickerIntentMoveTab, intent)
171	require.Equal(t, domain.TabStableID("active-tab"), captured.TabID)
172	require.Equal(t, domain.PaneStableID(""), captured.PaneID)
173	require.Same(t, ac, captured.Attachment)
174}
175
176func TestPaletteMoveWithoutDestinationShowsToastAndNoPicker(t *testing.T) {
177	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
178	defer releaseAll(releases)
179	d.mu.Lock()
180	delete(d.sessions, domain.SessionID("destination"))
181	d.mu.Unlock()
182
183	d.handleInput(source, ac, []byte("\x1b "))
184	d.handleInput(source, ac, []byte("MFP\r"))
185
186	require.False(t, ac.overlays.pickerClientActive())
187	history := d.notices.history()
188	require.NotEmpty(t, history)
189	require.Equal(t, "No destination available.", history[0].Message)
190}
191
192func TestMovePickerMoveCommitsPane(t *testing.T) {
193	d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
194	defer releaseAll(releases)
195	sourceTab := source.tabs[0]
196	moved := sourceTab.focusedPane()
197
198	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
199	effect := pickActionEffectForTest(t, source, ac)
200	selection := moveSelectionForDestination(t, ac, destination.id)
201	effect.End()
202	d.resolvePickerSelection(effect, selection)
203
204	require.Nil(t, source.tabs)
205	require.Same(t, moved, destinationTab.panes[moved.id])
206	require.Same(t, destination, moved.ownerSnapshot().session)
207}
208
209func TestMovePickerCommitMovePaneViaSharedAPI(t *testing.T) {
210	d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
211	defer releaseAll(releases)
212	sourceTab := source.tabs[0]
213	moved := sourceTab.focusedPane()
214
215	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
216	selection := moveSelectionForDestination(t, ac, destination.id)
217	ac.overlays.pickerMu.Lock()
218	target, ok := ac.overlays.pickerKeys[selection.Key]
219	ac.overlays.pickerMu.Unlock()
220	require.True(t, ok)
221	require.NoError(t, d.commitMovePickerSelection(protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"), target, nil, nil))
222
223	require.Nil(t, source.tabs)
224	require.Same(t, moved, destinationTab.panes[moved.id])
225	require.Same(t, destination, moved.ownerSnapshot().session)
226}
227
228func TestMovePickerMoveCommitsTab(t *testing.T) {
229	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
230	defer releaseAll(releases)
231	movedTab := source.tabs[0]
232	movedTab.stableID = "moved-tab"
233	ac.setClientEnvironment([]string{"WAYLAND_DISPLAY=wayland-mover"})
234	destination.mu.Lock()
235	destination.env = []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old"}
236	destination.mu.Unlock()
237
238	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", ""))
239	effect := pickActionEffectForTest(t, source, ac)
240	selection := moveSelectionForDestination(t, ac, destination.id)
241	completed := make(chan struct{})
242	go func() {
243		d.resolvePickerSelection(effect, selection)
244		close(completed)
245	}()
246	awaitTestCompletion(t, completed, "move-tab picker selection deadlocked on its own attachment effect")
247
248	require.Nil(t, source.tabs)
249	require.Len(t, destination.tabs, 2)
250	require.Same(t, movedTab, destination.tabs[1])
251	destination.mu.Lock()
252	defer destination.mu.Unlock()
253	require.Equal(t, []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-mover"}, destination.env, "a followed move refreshes the destination from the moving attachment")
254}
255
256func TestMovePickerCancelPerformsNoMutation(t *testing.T) {
257	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
258	defer releaseAll(releases)
259	before := len(source.tabs)
260
261	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
262	effect := pickActionEffectForTest(t, source, ac)
263	ac.overlays.pickerMu.Lock()
264	interaction := ac.overlays.pickerInteraction
265	ac.overlays.pickerMu.Unlock()
266	require.True(t, d.closePickerForAttachment(ac, effect, interaction))
267
268	require.False(t, ac.overlays.pickerClientActive())
269	require.Len(t, source.tabs, before)
270}
271
272func TestMovePickerStaleDestinationReportsNotice(t *testing.T) {
273	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
274	defer releaseAll(releases)
275
276	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
277	effect := pickActionEffectForTest(t, source, ac)
278	selection := moveSelectionForDestination(t, ac, destination.id)
279	d.mu.Lock()
280	delete(d.sessions, destination.id)
281	d.mu.Unlock()
282
283	effect.End()
284	d.resolvePickerSelection(effect, selection)
285
286	history := d.notices.history()
287	require.NotEmpty(t, history)
288	require.Equal(t, "Destination is no longer available.", history[0].Message)
289	require.Len(t, source.tabs, 1)
290}
291
292func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
293	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
294	defer releaseAll(releases)
295	sourceTab := source.tabs[0]
296
297	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
298	effect := pickActionEffectForTest(t, source, ac)
299	selection := moveSelectionForDestination(t, ac, destination.id)
300	sourceTab.mu.Lock()
301	delete(sourceTab.panes, sourceTab.tree.Focus)
302	sourceTab.mu.Unlock()
303
304	effect.End()
305	d.resolvePickerSelection(effect, selection)
306
307	require.True(t, ac.overlays.pickerClientActive())
308	history := d.notices.history()
309	require.NotEmpty(t, history)
310	require.Equal(t, "Pane no longer exists.", history[0].Message)
311}
312
313func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
314	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
315	defer releaseAll(releases)
316
317	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "source-tab", ""))
318	effect := pickActionEffectForTest(t, source, ac)
319	selection := moveSelectionForDestination(t, ac, destination.id)
320	source.mu.Lock()
321	source.tabs = nil
322	source.mu.Unlock()
323
324	effect.End()
325	d.resolvePickerSelection(effect, selection)
326
327	require.True(t, ac.overlays.pickerClientActive())
328	history := d.notices.history()
329	require.NotEmpty(t, history)
330	require.Equal(t, "Tab no longer exists.", history[0].Message)
331}
332
333// TestMovePickerSupersedingOpenRetiresTheEarlierNamespace pins that a second
334// open moves the interaction on, so rows published for the retired namespace
335// can never be committed again.
336func TestMovePickerSupersedingOpenRetiresTheEarlierNamespace(t *testing.T) {
337	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
338	defer releaseAll(releases)
339	sourceSnapshot := moveSourceForSession(source, ac, "source-tab", "source-pane")
340	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot)
341	effect := pickActionEffectForTest(t, source, ac)
342	ac.overlays.pickerMu.Lock()
343	firstInteraction := ac.overlays.pickerInteraction
344	firstRevision := ac.overlays.pickerRevisions[servingPickerSourceID]
345	firstKey := ""
346	for candidate := range ac.overlays.pickerKeys {
347		firstKey = candidate
348		break
349	}
350	ac.overlays.pickerMu.Unlock()
351	require.NotEmpty(t, firstKey)
352
353	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot)
354	ac.overlays.pickerMu.Lock()
355	secondInteraction := ac.overlays.pickerInteraction
356	ac.overlays.pickerMu.Unlock()
357	require.Greater(t, secondInteraction, firstInteraction)
358
359	// A commit from the retired namespace is rejected and leaves the open
360	// interaction untouched.
361	d.resolvePickerSelection(effect, protocol.PickerSelection{
362		InteractionID: firstInteraction, SourceID: servingPickerSourceID,
363		SourceRevision: firstRevision, Key: firstKey, Action: protocol.PickerActionMove,
364	})
365	ac.overlays.pickerMu.Lock()
366	require.True(t, ac.overlays.pickerOpen)
367	require.Equal(t, secondInteraction, ac.overlays.pickerInteraction)
368	ac.overlays.pickerMu.Unlock()
369}
370
371// Exercise the real selection admission (not a pre-ended test effect), and
372// observe the publication boundary without sleeps or concurrent state reads.
373func TestMovePickerCompositeFollow(t *testing.T) {
374	for _, tt := range []struct {
375		name         string
376		intent       protocol.PickerIntent
377		retainSource bool
378	}{
379		{"final-tab", protocol.PickerIntentMoveTab, false},
380		{"retained-source-tab", protocol.PickerIntentMoveTab, true},
381		{"final-pane", protocol.PickerIntentMovePane, false},
382	} {
383		t.Run(tt.name, func(t *testing.T) {
384			d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
385			defer releaseAll(releases)
386			movedTab := source.tabs[0]
387			if tt.retainSource {
388				pty, release := newBlockingPTY(t)
389				defer release()
390				source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size))
391				publishTiledPaneOwners(source, source.tabs[1])
392			}
393			peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
394			peer.setSession(source)
395			source.registerAttachment(peer)
396			destinationPeer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
397			destinationPeer.setSession(destination)
398			destination.registerAttachment(destinationPeer)
399			destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID))
400			old := captureAttachmentCapability(source, ac, ac.transport())
401			locator := moveSourceForSession(source, ac, "source-tab", "")
402			locator.AttachmentCapability = old
403			if tt.intent == protocol.PickerIntentMovePane {
404				locator.PaneID = "source-pane"
405			}
406			openMovePickerForTest(t, d, ac, source, tt.intent, locator)
407			effect := pickActionEffectForTest(t, source, ac)
408			selection := moveSelectionForDestination(t, ac, destination.id)
409			closedAtCommit := false
410			rebased := make(chan struct{}, 1)
411			ac.renderStages.handoffRebase = func() { rebased <- struct{}{} }
412			hook := func() { closedAtCommit = !ac.overlays.pickerClientActive() }
413			d.beforeMoveTabCommit = hook
414			d.beforeMovePaneCommit = hook
415			done := make(chan struct{})
416			go func() {
417				d.resolvePickerSelection(effect, selection)
418				close(done)
419			}()
420			awaitTestCompletion(t, done, "composite move waited on its own effect")
421			require.True(t, closedAtCommit)
422			select {
423			case <-rebased:
424			default:
425				t.Fatal("composite move skipped transition first-paint rebase")
426			}
427			require.True(t, destination.attachmentRegistered(destinationPeer))
428			require.Same(t, destinationTab, destination.tabForAttachment(destinationPeer))
429			require.False(t, ac.overlays.pickerClientActive())
430			require.Same(t, destination, ac.currentSession())
431			require.True(t, destination.attachmentRegistered(ac))
432			require.False(t, source.attachmentRegistered(ac))
433			require.False(t, old.current())
434			if tt.intent == protocol.PickerIntentMoveTab {
435				require.Same(t, movedTab, destination.tabForAttachment(ac))
436			} else {
437				require.Same(t, destinationTab, destination.tabForAttachment(ac))
438			}
439			if tt.retainSource {
440				require.True(t, source.attachmentRegistered(peer))
441				require.Same(t, source, d.sessionByID(source.id))
442			} else {
443				require.False(t, source.attachmentRegistered(peer))
444				require.Nil(t, d.sessionByID(source.id))
445			}
446		})
447	}
448}
449
450// A composite follow ends the committing effect so the move can drain its
451// source. Its completion must still reach the client: the postcommit admits a
452// fresh effect on the published destination capability and reports the
453// correlated PickerResult the ui-driver action waits for.
454func TestMovePickerCompositeFollowReportsCorrelatedResult(t *testing.T) {
455	d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
456	defer releaseAll(releases)
457	old := captureAttachmentCapability(source, ac, ac.transport())
458	locator := moveSourceForSession(source, ac, "source-tab", "")
459	locator.AttachmentCapability = old
460	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
461	effect := pickActionEffectForTest(t, source, ac)
462	selection := moveSelectionForDestination(t, ac, destination.id)
463	selection.CauseActionID = 4242
464	done := make(chan struct{})
465	go func() {
466		d.resolvePickerSelection(effect, selection)
467		close(done)
468	}()
469	awaitTestCompletion(t, done, "composite follow never reported completion")
470
471	result := awaitSentPickerResult(t, sends)
472	require.Equal(t, selection.CauseActionID, result.CauseActionID)
473	require.Equal(t, selection.InteractionID, result.InteractionID)
474	require.Equal(t, selection.Key, result.Key)
475	require.Equal(t, protocol.PickerActionMove, result.Action)
476	assertNoFurtherFrame(t, sends, "PickerResult", "composite follow reported its result more than once")
477}
478
479// TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability pins the
480// non-admission window: when the exact capability the follow published stops
481// being admissible between its identity send and the postcommit completion (a
482// concurrent link replacement while the attachment stays live), the completion
483// must be re-admitted on the attachment's current capability and reported
484// exactly once, instead of only logging while the ui-driver action runs to its
485// deadline.
486func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *testing.T) {
487	d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
488	defer releaseAll(releases)
489	base, ok := ac.transport().(*mockServerConnection)
490	require.True(t, ok, "fixture transport is not the capturing mock")
491	old := captureAttachmentCapability(source, ac, ac.transport())
492	locator := moveSourceForSession(source, ac, "source-tab", "")
493	locator.AttachmentCapability = old
494	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
495	effect := pickActionEffectForTest(t, source, ac)
496	selection := moveSelectionForDestination(t, ac, destination.id)
497	selection.CauseActionID = 5252
498
499	// Supersede the exact published capability after the follow's identity send
500	// and first paint but before completion admission. The replacement keeps the
501	// attachment live on its current session while retiring the published link.
502	// A render effect may still be in flight, so this reproduces the transition's
503	// end state (a newer published capability for the replacement link) rather
504	// than requiring a drained gate.
505	replaced := make(chan attachmentCapability, 1)
506	d.beforeMoveFollowCompletion = func(published attachmentCapability) {
507		supersedeAttachmentCapabilityForTest(published, base)
508		replaced <- published
509	}
510	done := make(chan struct{})
511	go func() {
512		d.resolvePickerSelection(effect, selection)
513		close(done)
514	}()
515	awaitTestCompletion(t, done, "composite follow never reported completion")
516	published := <-replaced
517	require.False(t, published.current(), "the superseded published capability was still admissible")
518
519	// The completion must arrive through the current capability, exactly once.
520	result := awaitSentPickerResult(t, sends)
521	require.Equal(t, selection.CauseActionID, result.CauseActionID)
522	require.Equal(t, selection.InteractionID, result.InteractionID)
523	require.Equal(t, protocol.PickerActionMove, result.Action)
524	assertNoFurtherFrame(t, sends, "PickerResult", "composite follow reported its result more than once")
525}
526
527// supersedeAttachmentCapabilityForTest retires an attachment's exact published
528// capability for a replacement link that keeps the attachment live, modelling a
529// concurrent reconnect or transition that won the gate after publication. It
530// republishes the current capability directly so it also works while a render
531// effect may still be in flight.
532type replacementServerConnection struct{ *mockServerConnection }
533
534func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) {
535	wrapper := &replacementServerConnection{mockServerConnection: base}
536	published.ac.replaceTransport(wrapper)
537	current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper)
538	gate := &published.ac.lifecycle
539	gate.mu.Lock()
540	gate.initLocked()
541	current.generation = gate.generation.Add(1)
542	gate.capability = current
543	gate.failedTransport = transportSnapshot{}
544	gate.mu.Unlock()
545}
546
547// TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity pins
548// the earlier non-admission window: when the exact capability the follow
549// published is superseded before finishAttachmentTransition admits its
550// committed route identity (a concurrent link replacement while the attachment
551// stays live), the transition must still send the committed identity, perform
552// the authoritative first paint, and report completion exactly once on the
553// attachment's current capability. Aborting instead would leave the post-close
554// drain and the ui-driver action pending forever.
555func TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity(t *testing.T) {
556	d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
557	defer releaseAll(releases)
558	base, ok := ac.transport().(*mockServerConnection)
559	require.True(t, ok, "fixture transport is not the capturing mock")
560	// A published route ledger is what makes a ready transition owe its
561	// committed identity to the client.
562	ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
563	old := captureAttachmentCapability(source, ac, ac.transport())
564	locator := moveSourceForSession(source, ac, "source-tab", "")
565	locator.AttachmentCapability = old
566	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
567	effect := pickActionEffectForTest(t, source, ac)
568	selection := moveSelectionForDestination(t, ac, destination.id)
569	selection.CauseActionID = 6161
570
571	rebased := make(chan struct{}, 1)
572	ac.renderStages.handoffRebase = func() { rebased <- struct{}{} }
573
574	// Supersede the published capability before the transition admits its
575	// committed route identity, exactly in the window final review flagged.
576	superseded := make(chan attachmentCapability, 1)
577	d.beforeAttachmentTransitionIdentityAdmission = func(published attachmentCapability) {
578		supersedeAttachmentCapabilityForTest(published, base)
579		superseded <- published
580	}
581	done := make(chan struct{})
582	go func() {
583		d.resolvePickerSelection(effect, selection)
584		close(done)
585	}()
586	awaitTestCompletion(t, done, "superseded composite follow never completed")
587	published := <-superseded
588	require.False(t, published.current(), "the superseded published capability was still admissible")
589
590	select {
591	case <-rebased:
592	default:
593		t.Fatal("superseded composite follow skipped its authoritative first paint")
594	}
595
596	// The committed identity must reach the live replacement link for the
597	// session the attachment actually holds, exactly once, and the correlated
598	// completion must follow exactly once.
599	identity := awaitSentCommittedIdentity(t, sends)
600	require.Equal(t, destination.name, identity.Target.SessionName)
601	require.Equal(t, destination.incarnation, identity.Target.LifecycleID)
602	result := awaitSentPickerResult(t, sends)
603	require.Equal(t, selection.CauseActionID, result.CauseActionID)
604	require.Equal(t, selection.InteractionID, result.InteractionID)
605	require.Equal(t, protocol.PickerActionMove, result.Action)
606	assertNoFurtherFrame(t, sends, "CommittedRouteIdentity", "composite follow published its committed identity more than once")
607	assertNoFurtherFrame(t, sends, "PickerResult", "composite follow reported its result more than once")
608}
609
610// awaitSentCommittedIdentity reads the next committed route identity the daemon
611// published, skipping unrelated frames.
612func awaitSentCommittedIdentity(t *testing.T, sends <-chan wire.Envelope) protocol.CommittedRouteIdentity {
613	t.Helper()
614	deadline := time.After(2 * time.Second)
615	for {
616		select {
617		case frame := <-sends:
618			identity, ok := decodeServerMessage(t, frame).(protocol.CommittedRouteIdentity)
619			if !ok {
620				continue
621			}
622			return identity
623		case <-deadline:
624			t.Fatal("composite follow sent no committed route identity")
625			return protocol.CommittedRouteIdentity{}
626		}
627	}
628}
629
630// assertNoFurtherFrame proves a frame was produced exactly once: no later frame
631// of that type may follow the one the caller already observed.
632func assertNoFurtherFrame(t *testing.T, sends <-chan wire.Envelope, frameName string, failure string) {
633	t.Helper()
634	deadline := time.After(200 * time.Millisecond)
635	for {
636		select {
637		case frame := <-sends:
638			require.NotEqual(t, frameName, envelopeMessageName(t, frame.Payload), failure)
639		case <-deadline:
640			return
641		}
642	}
643}
644
645// A rejection after the picker closed must not touch the ended effect. It
646// delivers bounded feedback and a repaint through the attachment's current
647// capability so the client's post-close drain can release the terminal.
648func TestMovePickerClosedRejectionUsesFreshEffect(t *testing.T) {
649	d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
650	defer releaseAll(releases)
651	old := captureAttachmentCapability(source, ac, ac.transport())
652	locator := moveSourceForSession(source, ac, "source-tab", "")
653	locator.AttachmentCapability = old
654	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
655	effect := pickActionEffectForTest(t, source, ac)
656	// Publication must reject after the picker closed: changing the destination
657	// tab geometry re-admits the already-validated topology as stale.
658	d.afterMoveTabSourceSnapshot = func() {
659		destinationTab.mu.Lock()
660		destinationTab.size = domain.Size{Cols: 79, Rows: 22}
661		destinationTab.mu.Unlock()
662	}
663	selection := moveSelectionForDestination(t, ac, destination.id)
664	selection.CauseActionID = 99
665	done := make(chan struct{})
666	go func() {
667		d.resolvePickerSelection(effect, selection)
668		close(done)
669	}()
670	awaitTestCompletion(t, done, "closed rejection never returned")
671
672	failure, result := collectSentPickerOutcome(t, sends)
673	require.Nil(t, result, "rejected composite move reported success")
674	require.Equal(t, selection.CauseActionID, failure.CauseActionID)
675	require.Equal(t, protocol.PickerActionFailed, failure.Code)
676}
677
678// awaitSentPickerResult blocks until the daemon reports a completed mutation.
679func awaitSentPickerResult(t *testing.T, sends <-chan wire.Envelope) protocol.PickerResult {
680	t.Helper()
681	deadline := time.After(2 * time.Second)
682	for {
683		select {
684		case frame := <-sends:
685			result, ok := decodeServerMessage(t, frame).(protocol.PickerResult)
686			if !ok {
687				continue
688			}
689			return result
690		case <-deadline:
691			t.Fatal("composite follow sent no correlated PickerResult")
692			return protocol.PickerResult{}
693		}
694	}
695}
696
697// collectSentPickerOutcome drains the bounded set of frames one rejection can
698// produce and returns the PickerFailure, plus any PickerResult that must not
699// exist on the rejection path.
700func collectSentPickerOutcome(t *testing.T, sends <-chan wire.Envelope) (failure protocol.PickerFailure, result *protocol.PickerResult) {
701	t.Helper()
702	found := false
703	deadline := time.After(500 * time.Millisecond)
704	for {
705		select {
706		case frame := <-sends:
707			switch message := decodeServerMessage(t, frame).(type) {
708			case protocol.PickerFailure:
709				failure, found = message, true
710			case protocol.PickerResult:
711				result = &message
712			}
713		case <-deadline:
714			require.True(t, found, "closed rejection sent no bounded failure")
715			return failure, result
716		}
717	}
718}

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