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}