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}