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
234	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", ""))
235	effect := pickActionEffectForTest(t, source, ac)
236	selection := moveSelectionForDestination(t, ac, destination.id)
237	completed := make(chan struct{})
238	go func() {
239		d.resolvePickerSelection(effect, selection)
240		close(completed)
241	}()
242	awaitTestCompletion(t, completed, "move-tab picker selection deadlocked on its own attachment effect")
243
244	require.Nil(t, source.tabs)
245	require.Len(t, destination.tabs, 2)
246	require.Same(t, movedTab, destination.tabs[1])
247}
248
249func TestMovePickerCancelPerformsNoMutation(t *testing.T) {
250	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
251	defer releaseAll(releases)
252	before := len(source.tabs)
253
254	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
255	effect := pickActionEffectForTest(t, source, ac)
256	ac.overlays.pickerMu.Lock()
257	interaction := ac.overlays.pickerInteraction
258	ac.overlays.pickerMu.Unlock()
259	require.True(t, d.closePickerForAttachment(ac, effect, interaction))
260
261	require.False(t, ac.overlays.pickerClientActive())
262	require.Len(t, source.tabs, before)
263}
264
265func TestMovePickerStaleDestinationReportsNotice(t *testing.T) {
266	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
267	defer releaseAll(releases)
268
269	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
270	effect := pickActionEffectForTest(t, source, ac)
271	selection := moveSelectionForDestination(t, ac, destination.id)
272	d.mu.Lock()
273	delete(d.sessions, destination.id)
274	d.mu.Unlock()
275
276	effect.End()
277	d.resolvePickerSelection(effect, selection)
278
279	history := d.notices.history()
280	require.NotEmpty(t, history)
281	require.Equal(t, "Destination is no longer available.", history[0].Message)
282	require.Len(t, source.tabs, 1)
283}
284
285func TestMovePickerStaleSourcePaneReportsPreciseFeedback(t *testing.T) {
286	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
287	defer releaseAll(releases)
288	sourceTab := source.tabs[0]
289
290	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, moveSourceForSession(source, ac, "source-tab", "source-pane"))
291	effect := pickActionEffectForTest(t, source, ac)
292	selection := moveSelectionForDestination(t, ac, destination.id)
293	sourceTab.mu.Lock()
294	delete(sourceTab.panes, sourceTab.tree.Focus)
295	sourceTab.mu.Unlock()
296
297	effect.End()
298	d.resolvePickerSelection(effect, selection)
299
300	require.True(t, ac.overlays.pickerClientActive())
301	history := d.notices.history()
302	require.NotEmpty(t, history)
303	require.Equal(t, "Pane no longer exists.", history[0].Message)
304}
305
306func TestMovePickerStaleSourceTabReportsPreciseFeedback(t *testing.T) {
307	d, source, ac, destination, _, releases := setupMovePickerSessions(t, 0)
308	defer releaseAll(releases)
309
310	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "source-tab", ""))
311	effect := pickActionEffectForTest(t, source, ac)
312	selection := moveSelectionForDestination(t, ac, destination.id)
313	source.mu.Lock()
314	source.tabs = nil
315	source.mu.Unlock()
316
317	effect.End()
318	d.resolvePickerSelection(effect, selection)
319
320	require.True(t, ac.overlays.pickerClientActive())
321	history := d.notices.history()
322	require.NotEmpty(t, history)
323	require.Equal(t, "Tab no longer exists.", history[0].Message)
324}
325
326// TestMovePickerSupersedingOpenRetiresTheEarlierNamespace pins that a second
327// open moves the interaction on, so rows published for the retired namespace
328// can never be committed again.
329func TestMovePickerSupersedingOpenRetiresTheEarlierNamespace(t *testing.T) {
330	d, source, ac, _, _, releases := setupMovePickerSessions(t, 0)
331	defer releaseAll(releases)
332	sourceSnapshot := moveSourceForSession(source, ac, "source-tab", "source-pane")
333	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot)
334	effect := pickActionEffectForTest(t, source, ac)
335	ac.overlays.pickerMu.Lock()
336	firstInteraction := ac.overlays.pickerInteraction
337	firstRevision := ac.overlays.pickerRevisions[servingPickerSourceID]
338	firstKey := ""
339	for candidate := range ac.overlays.pickerKeys {
340		firstKey = candidate
341		break
342	}
343	ac.overlays.pickerMu.Unlock()
344	require.NotEmpty(t, firstKey)
345
346	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMovePane, sourceSnapshot)
347	ac.overlays.pickerMu.Lock()
348	secondInteraction := ac.overlays.pickerInteraction
349	ac.overlays.pickerMu.Unlock()
350	require.Greater(t, secondInteraction, firstInteraction)
351
352	// A commit from the retired namespace is rejected and leaves the open
353	// interaction untouched.
354	d.resolvePickerSelection(effect, protocol.PickerSelection{
355		InteractionID: firstInteraction, SourceID: servingPickerSourceID,
356		SourceRevision: firstRevision, Key: firstKey, Action: protocol.PickerActionMove,
357	})
358	ac.overlays.pickerMu.Lock()
359	require.True(t, ac.overlays.pickerOpen)
360	require.Equal(t, secondInteraction, ac.overlays.pickerInteraction)
361	ac.overlays.pickerMu.Unlock()
362}
363
364// Exercise the real selection admission (not a pre-ended test effect), and
365// observe the publication boundary without sleeps or concurrent state reads.
366func TestMovePickerCompositeFollow(t *testing.T) {
367	for _, tt := range []struct {
368		name         string
369		intent       protocol.PickerIntent
370		retainSource bool
371	}{
372		{"final-tab", protocol.PickerIntentMoveTab, false},
373		{"retained-source-tab", protocol.PickerIntentMoveTab, true},
374		{"final-pane", protocol.PickerIntentMovePane, false},
375	} {
376		t.Run(tt.name, func(t *testing.T) {
377			d, source, ac, destination, destinationTab, releases := setupMovePickerSessions(t, 0)
378			defer releaseAll(releases)
379			movedTab := source.tabs[0]
380			if tt.retainSource {
381				pty, release := newBlockingPTY(t)
382				defer release()
383				source.tabs = append(source.tabs, newTabWithStableID("remaining", "remaining-pane", pty, movedTab.size))
384				publishTiledPaneOwners(source, source.tabs[1])
385			}
386			peer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
387			peer.setSession(source)
388			source.registerAttachment(peer)
389			destinationPeer := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: ac.size}
390			destinationPeer.setSession(destination)
391			destination.registerAttachment(destinationPeer)
392			destination.selectAttachmentTabLocked(destinationPeer, domain.TabStableID(destinationTab.stableID))
393			old := captureAttachmentCapability(source, ac, ac.transport())
394			locator := moveSourceForSession(source, ac, "source-tab", "")
395			locator.AttachmentCapability = old
396			if tt.intent == protocol.PickerIntentMovePane {
397				locator.PaneID = "source-pane"
398			}
399			openMovePickerForTest(t, d, ac, source, tt.intent, locator)
400			effect := pickActionEffectForTest(t, source, ac)
401			selection := moveSelectionForDestination(t, ac, destination.id)
402			closedAtCommit := false
403			rebased := make(chan struct{}, 1)
404			ac.renderStages.handoffRebase = func() { rebased <- struct{}{} }
405			hook := func() { closedAtCommit = !ac.overlays.pickerClientActive() }
406			d.beforeMoveTabCommit = hook
407			d.beforeMovePaneCommit = hook
408			done := make(chan struct{})
409			go func() {
410				d.resolvePickerSelection(effect, selection)
411				close(done)
412			}()
413			awaitTestCompletion(t, done, "composite move waited on its own effect")
414			require.True(t, closedAtCommit)
415			select {
416			case <-rebased:
417			default:
418				t.Fatal("composite move skipped transition first-paint rebase")
419			}
420			require.True(t, destination.attachmentRegistered(destinationPeer))
421			require.Same(t, destinationTab, destination.tabForAttachment(destinationPeer))
422			require.False(t, ac.overlays.pickerClientActive())
423			require.Same(t, destination, ac.currentSession())
424			require.True(t, destination.attachmentRegistered(ac))
425			require.False(t, source.attachmentRegistered(ac))
426			require.False(t, old.current())
427			if tt.intent == protocol.PickerIntentMoveTab {
428				require.Same(t, movedTab, destination.tabForAttachment(ac))
429			} else {
430				require.Same(t, destinationTab, destination.tabForAttachment(ac))
431			}
432			if tt.retainSource {
433				require.True(t, source.attachmentRegistered(peer))
434				require.Same(t, source, d.sessionByID(source.id))
435			} else {
436				require.False(t, source.attachmentRegistered(peer))
437				require.Nil(t, d.sessionByID(source.id))
438			}
439		})
440	}
441}
442
443// A composite follow ends the committing effect so the move can drain its
444// source. Its completion must still reach the client: the postcommit admits a
445// fresh effect on the published destination capability and reports the
446// correlated PickerResult the ui-driver action waits for.
447func TestMovePickerCompositeFollowReportsCorrelatedResult(t *testing.T) {
448	d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
449	defer releaseAll(releases)
450	old := captureAttachmentCapability(source, ac, ac.transport())
451	locator := moveSourceForSession(source, ac, "source-tab", "")
452	locator.AttachmentCapability = old
453	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
454	effect := pickActionEffectForTest(t, source, ac)
455	selection := moveSelectionForDestination(t, ac, destination.id)
456	selection.CauseActionID = 4242
457	done := make(chan struct{})
458	go func() {
459		d.resolvePickerSelection(effect, selection)
460		close(done)
461	}()
462	awaitTestCompletion(t, done, "composite follow never reported completion")
463
464	result := awaitSentPickerResult(t, sends)
465	require.Equal(t, selection.CauseActionID, result.CauseActionID)
466	require.Equal(t, selection.InteractionID, result.InteractionID)
467	require.Equal(t, selection.Key, result.Key)
468	require.Equal(t, protocol.PickerActionMove, result.Action)
469	assertNoFurtherFrame(t, sends, "PickerResult", "composite follow reported its result more than once")
470}
471
472// TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability pins the
473// non-admission window: when the exact capability the follow published stops
474// being admissible between its identity send and the postcommit completion (a
475// concurrent link replacement while the attachment stays live), the completion
476// must be re-admitted on the attachment's current capability and reported
477// exactly once, instead of only logging while the ui-driver action runs to its
478// deadline.
479func TestMovePickerCompositeFollowCompletionReadmitsSupersededCapability(t *testing.T) {
480	d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
481	defer releaseAll(releases)
482	base, ok := ac.transport().(*mockServerConnection)
483	require.True(t, ok, "fixture transport is not the capturing mock")
484	old := captureAttachmentCapability(source, ac, ac.transport())
485	locator := moveSourceForSession(source, ac, "source-tab", "")
486	locator.AttachmentCapability = old
487	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
488	effect := pickActionEffectForTest(t, source, ac)
489	selection := moveSelectionForDestination(t, ac, destination.id)
490	selection.CauseActionID = 5252
491
492	// Supersede the exact published capability after the follow's identity send
493	// and first paint but before completion admission. The replacement keeps the
494	// attachment live on its current session while retiring the published link.
495	// A render effect may still be in flight, so this reproduces the transition's
496	// end state (a newer published capability for the replacement link) rather
497	// than requiring a drained gate.
498	replaced := make(chan attachmentCapability, 1)
499	d.beforeMoveFollowCompletion = func(published attachmentCapability) {
500		supersedeAttachmentCapabilityForTest(published, base)
501		replaced <- published
502	}
503	done := make(chan struct{})
504	go func() {
505		d.resolvePickerSelection(effect, selection)
506		close(done)
507	}()
508	awaitTestCompletion(t, done, "composite follow never reported completion")
509	published := <-replaced
510	require.False(t, published.current(), "the superseded published capability was still admissible")
511
512	// The completion must arrive through the current capability, exactly once.
513	result := awaitSentPickerResult(t, sends)
514	require.Equal(t, selection.CauseActionID, result.CauseActionID)
515	require.Equal(t, selection.InteractionID, result.InteractionID)
516	require.Equal(t, protocol.PickerActionMove, result.Action)
517	assertNoFurtherFrame(t, sends, "PickerResult", "composite follow reported its result more than once")
518}
519
520// supersedeAttachmentCapabilityForTest retires an attachment's exact published
521// capability for a replacement link that keeps the attachment live, modelling a
522// concurrent reconnect or transition that won the gate after publication. It
523// republishes the current capability directly so it also works while a render
524// effect may still be in flight.
525type replacementServerConnection struct{ *mockServerConnection }
526
527func supersedeAttachmentCapabilityForTest(published attachmentCapability, base *mockServerConnection) {
528	wrapper := &replacementServerConnection{mockServerConnection: base}
529	published.ac.replaceTransport(wrapper)
530	current := captureAttachmentCapability(published.ac.currentAttachmentSession(), published.ac, wrapper)
531	gate := &published.ac.lifecycle
532	gate.mu.Lock()
533	gate.initLocked()
534	current.generation = gate.generation.Add(1)
535	gate.capability = current
536	gate.failedTransport = transportSnapshot{}
537	gate.mu.Unlock()
538}
539
540// TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity pins
541// the earlier non-admission window: when the exact capability the follow
542// published is superseded before finishAttachmentTransition admits its
543// committed route identity (a concurrent link replacement while the attachment
544// stays live), the transition must still send the committed identity, perform
545// the authoritative first paint, and report completion exactly once on the
546// attachment's current capability. Aborting instead would leave the post-close
547// drain and the ui-driver action pending forever.
548func TestMovePickerCompositeFollowReadmitsSupersededCapabilityBeforeIdentity(t *testing.T) {
549	d, source, ac, destination, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
550	defer releaseAll(releases)
551	base, ok := ac.transport().(*mockServerConnection)
552	require.True(t, ok, "fixture transport is not the capturing mock")
553	// A published route ledger is what makes a ready transition owe its
554	// committed identity to the client.
555	ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
556	old := captureAttachmentCapability(source, ac, ac.transport())
557	locator := moveSourceForSession(source, ac, "source-tab", "")
558	locator.AttachmentCapability = old
559	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
560	effect := pickActionEffectForTest(t, source, ac)
561	selection := moveSelectionForDestination(t, ac, destination.id)
562	selection.CauseActionID = 6161
563
564	rebased := make(chan struct{}, 1)
565	ac.renderStages.handoffRebase = func() { rebased <- struct{}{} }
566
567	// Supersede the published capability before the transition admits its
568	// committed route identity, exactly in the window final review flagged.
569	superseded := make(chan attachmentCapability, 1)
570	d.beforeAttachmentTransitionIdentityAdmission = func(published attachmentCapability) {
571		supersedeAttachmentCapabilityForTest(published, base)
572		superseded <- published
573	}
574	done := make(chan struct{})
575	go func() {
576		d.resolvePickerSelection(effect, selection)
577		close(done)
578	}()
579	awaitTestCompletion(t, done, "superseded composite follow never completed")
580	published := <-superseded
581	require.False(t, published.current(), "the superseded published capability was still admissible")
582
583	select {
584	case <-rebased:
585	default:
586		t.Fatal("superseded composite follow skipped its authoritative first paint")
587	}
588
589	// The committed identity must reach the live replacement link for the
590	// session the attachment actually holds, exactly once, and the correlated
591	// completion must follow exactly once.
592	identity := awaitSentCommittedIdentity(t, sends)
593	require.Equal(t, destination.name, identity.Target.SessionName)
594	require.Equal(t, destination.incarnation, identity.Target.LifecycleID)
595	result := awaitSentPickerResult(t, sends)
596	require.Equal(t, selection.CauseActionID, result.CauseActionID)
597	require.Equal(t, selection.InteractionID, result.InteractionID)
598	require.Equal(t, protocol.PickerActionMove, result.Action)
599	assertNoFurtherFrame(t, sends, "CommittedRouteIdentity", "composite follow published its committed identity more than once")
600	assertNoFurtherFrame(t, sends, "PickerResult", "composite follow reported its result more than once")
601}
602
603// awaitSentCommittedIdentity reads the next committed route identity the daemon
604// published, skipping unrelated frames.
605func awaitSentCommittedIdentity(t *testing.T, sends <-chan wire.Envelope) protocol.CommittedRouteIdentity {
606	t.Helper()
607	deadline := time.After(2 * time.Second)
608	for {
609		select {
610		case frame := <-sends:
611			identity, ok := decodeServerMessage(t, frame).(protocol.CommittedRouteIdentity)
612			if !ok {
613				continue
614			}
615			return identity
616		case <-deadline:
617			t.Fatal("composite follow sent no committed route identity")
618			return protocol.CommittedRouteIdentity{}
619		}
620	}
621}
622
623// assertNoFurtherFrame proves a frame was produced exactly once: no later frame
624// of that type may follow the one the caller already observed.
625func assertNoFurtherFrame(t *testing.T, sends <-chan wire.Envelope, frameName string, failure string) {
626	t.Helper()
627	deadline := time.After(200 * time.Millisecond)
628	for {
629		select {
630		case frame := <-sends:
631			require.NotEqual(t, frameName, envelopeMessageName(t, frame.Payload), failure)
632		case <-deadline:
633			return
634		}
635	}
636}
637
638// A rejection after the picker closed must not touch the ended effect. It
639// delivers bounded feedback and a repaint through the attachment's current
640// capability so the client's post-close drain can release the terminal.
641func TestMovePickerClosedRejectionUsesFreshEffect(t *testing.T) {
642	d, source, ac, destination, destinationTab, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
643	defer releaseAll(releases)
644	old := captureAttachmentCapability(source, ac, ac.transport())
645	locator := moveSourceForSession(source, ac, "source-tab", "")
646	locator.AttachmentCapability = old
647	openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, locator)
648	effect := pickActionEffectForTest(t, source, ac)
649	// Publication must reject after the picker closed: changing the destination
650	// tab geometry re-admits the already-validated topology as stale.
651	d.afterMoveTabSourceSnapshot = func() {
652		destinationTab.mu.Lock()
653		destinationTab.size = domain.Size{Cols: 79, Rows: 22}
654		destinationTab.mu.Unlock()
655	}
656	selection := moveSelectionForDestination(t, ac, destination.id)
657	selection.CauseActionID = 99
658	done := make(chan struct{})
659	go func() {
660		d.resolvePickerSelection(effect, selection)
661		close(done)
662	}()
663	awaitTestCompletion(t, done, "closed rejection never returned")
664
665	failure, result := collectSentPickerOutcome(t, sends)
666	require.Nil(t, result, "rejected composite move reported success")
667	require.Equal(t, selection.CauseActionID, failure.CauseActionID)
668	require.Equal(t, protocol.PickerActionFailed, failure.Code)
669}
670
671// awaitSentPickerResult blocks until the daemon reports a completed mutation.
672func awaitSentPickerResult(t *testing.T, sends <-chan wire.Envelope) protocol.PickerResult {
673	t.Helper()
674	deadline := time.After(2 * time.Second)
675	for {
676		select {
677		case frame := <-sends:
678			result, ok := decodeServerMessage(t, frame).(protocol.PickerResult)
679			if !ok {
680				continue
681			}
682			return result
683		case <-deadline:
684			t.Fatal("composite follow sent no correlated PickerResult")
685			return protocol.PickerResult{}
686		}
687	}
688}
689
690// collectSentPickerOutcome drains the bounded set of frames one rejection can
691// produce and returns the PickerFailure, plus any PickerResult that must not
692// exist on the rejection path.
693func collectSentPickerOutcome(t *testing.T, sends <-chan wire.Envelope) (failure protocol.PickerFailure, result *protocol.PickerResult) {
694	t.Helper()
695	found := false
696	deadline := time.After(500 * time.Millisecond)
697	for {
698		select {
699		case frame := <-sends:
700			switch message := decodeServerMessage(t, frame).(type) {
701			case protocol.PickerFailure:
702				failure, found = message, true
703			case protocol.PickerResult:
704				result = &message
705			}
706		case <-deadline:
707			require.True(t, found, "closed rejection sent no bounded failure")
708			return failure, result
709		}
710	}
711}

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