1package client
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/catalogue"
16)
17
18// Picker overlay and move picker tests (Plan 003 A1/A2). They drive the real
19// supervisor, host, foreground, and worker over the in-package broker fakes
20// with the scripted test clock: no sleeps, and every wait is bounded.
21
22// recordOp records one picker decision on the scripted picker, exactly like
23// the controller's coalesced wake.
24func (p *attachTestPicker) recordOp(op pickerOp, key string, request ports.BrokerOpenStreamRequest) {
25 p.mu.Lock()
26 p.op = op
27 p.key = key
28 p.request = request
29 p.mu.Unlock()
30 select {
31 case p.opsReady <- struct{}{}:
32 default:
33 }
34}
35
36// opTaken reports whether the supervisor consumed the recorded decision.
37func (p *attachTestPicker) opTaken() bool {
38 p.mu.Lock()
39 defer p.mu.Unlock()
40 return p.op == (pickerOp{})
41}
42
43// attachLiveSession commits one attachment on the harness and returns its
44// stream once the attachment is presented.
45func attachLiveSession(t *testing.T, harness *attachTestHarness, picker *attachTestPicker) *sessionTestStream {
46 t.Helper()
47 stream := newSessionTestStream()
48 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
49 return stream, nil
50 })
51 picker.commit(sessionTestRequest(true))
52 awaitStreamHello(t, &sync.Mutex{}, &stream)
53 deliverReadyStream(t, stream)
54 awaitAttachedState(t, harness.sup)
55 return stream
56}
57
58// publishLiveLocal publishes a broker catalogue whose local daemon runs
59// target, so an overlay commit to it may switch in place.
60func publishLiveLocal(harness *attachTestHarness, target protocol.ExactSessionTarget) {
61 harness.service.publishSnapshot(routeTestSnapshot(routeTestLocal(pickerTestSessionWithLifecycle(target.SessionName, target.LifecycleID))))
62}
63
64func awaitPresentation(t *testing.T, sup *Supervisor, want Presentation) {
65 t.Helper()
66 require.Eventually(t, func() bool { return sup.State().Presentation == want }, 5*time.Second, time.Millisecond, "presentation never became %s", want)
67}
68
69// awaitSent waits for the first client message matching match.
70func awaitSent(t *testing.T, stream *sessionTestStream, what string, match func(protocol.ClientMessage) bool) protocol.ClientMessage {
71 t.Helper()
72 var found protocol.ClientMessage
73 require.Eventually(t, func() bool {
74 for _, message := range stream.messages() {
75 if match(message) {
76 found = message
77 return true
78 }
79 }
80 return false
81 }, 5*time.Second, time.Millisecond, "client never sent %s", what)
82 return found
83}
84
85func countSent[T protocol.ClientMessage](stream *sessionTestStream) int {
86 count := 0
87 for _, message := range stream.messages() {
88 if _, ok := message.(T); ok {
89 count++
90 }
91 }
92 return count
93}
94
95func isSent[T protocol.ClientMessage](message protocol.ClientMessage) bool {
96 _, ok := message.(T)
97 return ok
98}
99
100// laterOutput is a full frame on a later epoch, which the attachment accepts
101// after its initial publication.
102func laterOutput(epoch, publication uint64, data string) protocol.Output {
103 output := sessionTestOutput(publication, data)
104 output.Epoch = epoch
105 return output
106}
107
108func navigationOffer(id uint64) protocol.PickerOffer {
109 return protocol.PickerOffer{InteractionID: id, Intent: protocol.PickerIntentNavigation, Title: " Sessions · recent "}
110}
111
112// TestPickerOverlayReturnsToLiveAttachment pins PA1: SSP composes the picker
113// over the live attachment, its input and output stay with the picker, and
114// every cancel (and a commit to the attached session) returns to the very same
115// attachment without reconnecting, then asks for an authoritative repaint.
116func TestPickerOverlayReturnsToLiveAttachment(t *testing.T) {
117 tests := []struct {
118 name string
119 op pickerOp
120 key string
121 }{
122 {name: "escape or q cancels", op: pickerOp{close: true}},
123 {name: "ctrl+c cancels without exiting", op: pickerOp{close: true, exit: true}},
124 {name: "commit to the attached session closes", op: pickerOp{commit: true}, key: "row"},
125 }
126 for _, tt := range tests {
127 t.Run(tt.name, func(t *testing.T) {
128 picker := newAttachTestPicker()
129 harness := startAttachHarness(t, picker)
130 stream := attachLiveSession(t, harness, picker)
131 foreground := harness.sup.attachments.authority.foreground()
132 require.NotNil(t, foreground)
133 require.False(t, picker.owns())
134
135 stream.deliver(navigationOffer(1))
136 awaitPresentation(t, harness.sup, PresentAttachedPicker)
137 require.True(t, picker.owns(), "the picker owns input while it is open")
138
139 // Input belongs to the picker: it is consumed, never sent as session
140 // Input.
141 harness.reader.push([]byte("j"))
142 require.Eventually(t, func() bool { return picker.consumedCount() == 1 }, 5*time.Second, time.Millisecond)
143 require.Zero(t, countSent[protocol.Input](stream), "picker input must never reach the session")
144
145 // Attachment output is applied and acknowledged, never written over
146 // the picker.
147 acks := countSent[protocol.Ack](stream)
148 stream.deliver(laterOutput(2, 2, "hidden-frame"))
149 require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
150 require.NotContains(t, harness.terminal.written(), "hidden-frame")
151
152 picker.recordOp(tt.op, tt.key, sessionTestRequest(true))
153 awaitPresentation(t, harness.sup, PresentAttached)
154 awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
155
156 require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "cancel must keep the same live attachment")
157 require.False(t, stream.closedNow())
158 require.Len(t, harness.service.openedRequests(), 1, "cancel must not reconnect")
159 require.Zero(t, countSent[protocol.Detach](stream))
160 require.False(t, picker.owns())
161
162 // The session owns input again.
163 harness.reader.push([]byte("z"))
164 input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
165 require.Equal(t, []byte("z"), input.Data)
166 select {
167 case <-harness.runDone:
168 t.Fatal("cancelling the overlay ended the process")
169 default:
170 }
171 })
172 }
173}
174
175// TestPickerOverlayCommitElsewhereSwitchesAttachment pins how a commit to
176// another session leaves the live attachment. A session on the same daemon
177// moves the attachment in place with no new stream or Hello; if the daemon
178// refuses, or the session is on another daemon, the attachment detaches
179// cleanly and the target attaches through Connecting, never through the plain
180// picker.
181func TestPickerOverlayCommitElsewhereSwitchesAttachment(t *testing.T) {
182 beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
183 tests := []struct {
184 name string
185 local bool
186 stopped bool // the catalogue shows the target stopped
187 inPlace bool // the daemon accepts the in-place switch; false refuses it
188 }{
189 {name: "same daemon switches in place", local: true, inPlace: true},
190 {name: "a refused in-place switch reconnects", local: true},
191 {name: "a stopped session reconnects at once", local: true, stopped: true},
192 {name: "another daemon reconnects"},
193 }
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 picker := newAttachTestPicker()
197 notices := make(chan LifecycleNotice, 8)
198 harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
199 cfg.NotifyLifecycle = func(notice LifecycleNotice) { notices <- notice }
200 })
201 if tt.stopped {
202 harness.service.publishSnapshot(routeTestSnapshot(routeTestLocal(catalogue.RemoteCatalogSession{LifecycleID: beta.LifecycleID, Name: beta.SessionName, State: catalogue.RemoteCatalogSessionDown})))
203 } else {
204 publishLiveLocal(harness, beta)
205 }
206 first := attachLiveSession(t, harness, picker)
207 foreground := harness.sup.attachments.authority.foreground()
208 first.deliver(navigationOffer(1))
209 awaitPresentation(t, harness.sup, PresentAttachedPicker)
210
211 var mu sync.Mutex
212 second := newSessionTestStream()
213 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
214 return second, nil
215 })
216 other := sessionTestRequest(tt.local)
217 other.Target = beta
218 picker.recordOp(pickerOp{commit: true}, "row", other)
219
220 if tt.local && !tt.stopped {
221 request := awaitSent(t, first, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest)
222 require.Equal(t, beta, request.Target)
223 awaitPresentation(t, harness.sup, PresentAttached)
224 if tt.inPlace {
225 destination := laterOutput(2, 2, "\x1b[Hbeta")
226 destination.Context.Route.Target = beta
227 first.deliver(destination)
228 require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "beta") }, 5*time.Second, time.Millisecond)
229 require.Same(t, foreground, harness.sup.attachments.authority.foreground(), "an in-place switch keeps the attachment")
230 require.Len(t, harness.service.openedRequests(), 1, "an in-place switch never reconnects")
231 require.Zero(t, countSent[protocol.Detach](first))
232 require.Zero(t, countSent[protocol.OutputResetRequest](first), "an in-place switch never repaints the source")
233 return
234 }
235 first.deliver(protocol.SamePeerSwitchFailure{RequestID: request.RequestID, Code: protocol.SamePeerSwitchStaleTarget})
236 }
237
238 awaitSent(t, first, "Detach", isSent[protocol.Detach])
239 awaitStreamHello(t, &mu, &second)
240 second.deliver(protocol.Welcome{SessionName: "beta"})
241 output := sessionTestOutput(1, "\x1b[Hbeta")
242 output.Context.Route.Target = other.Target
243 second.deliver(output)
244 awaitAttachedState(t, harness.sup)
245
246 opened := harness.service.openedRequests()
247 require.Len(t, opened, 2)
248 require.Equal(t, other.Target, opened[1].Target)
249 require.True(t, first.closedNow(), "the previous attachment is released")
250 if tt.stopped || !tt.local {
251 require.Zero(t, countSent[protocol.SamePeerSwitchRequest](first), "only a live session on the serving daemon is tried in place")
252 }
253 select {
254 case notice := <-notices:
255 t.Fatalf("a swap must not report a lifecycle transition to the picker: %+v", notice)
256 default:
257 }
258 })
259 }
260}
261
262// TestPickerOverlaySettlesUIActions pins UI-driver settlement across the
263// client picker overlay: the action that opened it settles on the daemon
264// receipt fenced on output the overlay applied without writing, an action the
265// picker consumes settles at the supervisor boundary without a daemon fence,
266// and a picker commit elsewhere follows the swap and settles on the
267// destination's first committed publication.
268func TestPickerOverlaySettlesUIActions(t *testing.T) {
269 alpha := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
270 beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
271 type actionOutcome struct {
272 result ports.UIActionResult
273 err error
274 }
275 tests := []struct {
276 name string
277 // overlayFirst opens the overlay before the action is admitted.
278 overlayFirst bool
279 keys []string
280 // commit scripts a picker commit elsewhere for the consumed read.
281 commit bool
282 // inPlace commits a session on the serving daemon, which switches the
283 // live attachment instead of reconnecting.
284 inPlace bool
285 wantTarget protocol.ExactSessionTarget
286 wantFence bool
287 }{
288 {name: "the opening action settles on its suppressed receipt", keys: []string{"Enter"}, wantTarget: alpha, wantFence: true},
289 {name: "a picker-consumed action settles without a daemon fence", overlayFirst: true, keys: []string{"j"}, wantTarget: alpha},
290 {name: "a picker commit elsewhere settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, wantTarget: beta},
291 {name: "an in-place picker commit settles on the destination", overlayFirst: true, keys: []string{"Enter"}, commit: true, inPlace: true, wantTarget: beta},
292 }
293 for _, tt := range tests {
294 t.Run(tt.name, func(t *testing.T) {
295 picker := newAttachTestPicker()
296 var ui *UI
297 harness := startAttachHarnessConfig(t, picker, func(cfg *SupervisorConfig) {
298 ui = NewUI(cfg.Terminal.(ports.UIState), cfg.Clock)
299 cfg.UI = ui
300 })
301 publishLiveLocal(harness, beta)
302 stream := attachLiveSession(t, harness, picker)
303 attached := sessionTestOutput(2, "\x1b[Hattached")
304 attached.Full, attached.Base, attached.New = false, 1, 2
305 stream.deliver(attached)
306 var snapshot ports.UISnapshot
307 require.Eventually(t, func() bool {
308 var err error
309 snapshot, err = ui.Capture(ui.Handle())
310 return err == nil && snapshot.Context.Status == ports.UIStatusAttached && snapshot.Context.ViewPublication == 2
311 }, 5*time.Second, time.Millisecond)
312 if tt.overlayFirst {
313 stream.deliver(navigationOffer(1))
314 awaitPresentation(t, harness.sup, PresentAttachedPicker)
315 // The composition paints the overlay with the live attachment's
316 // context, so the attachment stays the actionable generation.
317 overlayContext, ok := ui.OverlayContext()
318 require.True(t, ok)
319 require.Equal(t, ports.UIStatusAttached, overlayContext.Status)
320 require.Equal(t, snapshot.Context.Generation, overlayContext.Generation)
321 require.Equal(t, alpha, overlayContext.Route.Target)
322 }
323 var mu sync.Mutex
324 second := newSessionTestStream()
325 if tt.commit {
326 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
327 return second, nil
328 })
329 // A remote target is another daemon, so it swaps.
330 other := sessionTestRequest(tt.inPlace)
331 other.Target = beta
332 picker.decideOnConsume(pickerOp{commit: true}, other)
333 }
334 fences := countSent[protocol.UIFence](stream)
335 consumed := picker.consumedCount()
336
337 done := make(chan actionOutcome, 1)
338 go func() {
339 result, err := ui.Action(t.Context(), ports.UIActionRequest{Attachment: ui.Handle(), Generation: snapshot.Context.Generation, Keys: tt.keys})
340 done <- actionOutcome{result: result, err: err}
341 }()
342
343 if !tt.overlayFirst {
344 // The Enter reaches the daemon, whose command offers the picker;
345 // the palette-close output then arrives under the overlay.
346 fence := awaitSent(t, stream, "UIFence", isSent[protocol.UIFence]).(protocol.UIFence)
347 stream.deliver(navigationOffer(1))
348 awaitPresentation(t, harness.sup, PresentAttachedPicker)
349 hidden := sessionTestOutput(3, "\x1b[Hhidden")
350 hidden.Full, hidden.Base, hidden.New = false, 2, 3
351 stream.deliver(hidden)
352 require.Eventually(t, func() bool {
353 ui.mu.Lock()
354 defer ui.mu.Unlock()
355 return ui.suppressed.ViewPublication == 3
356 }, 5*time.Second, time.Millisecond, "the suppressed output was never applied")
357 stream.deliver(protocol.UIReceipt{ActionID: fence.ActionID, Epoch: 1, State: 3, ViewPublication: 3, Outcome: protocol.UIReceiptProcessed})
358 }
359 if tt.inPlace {
360 awaitSent(t, stream, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest])
361 destination := laterOutput(2, 3, "\x1b[Hbeta")
362 destination.Context.Route.Target = beta
363 stream.deliver(destination)
364 } else if tt.commit {
365 awaitSent(t, stream, "Detach", isSent[protocol.Detach])
366 awaitStreamHello(t, &mu, &second)
367 second.deliver(protocol.Welcome{SessionName: "beta"})
368 output := sessionTestOutput(1, "\x1b[Hbeta")
369 output.Context.Route.Target = beta
370 second.deliver(output)
371 }
372
373 var outcome actionOutcome
374 select {
375 case outcome = <-done:
376 case <-time.After(5 * time.Second):
377 t.Fatal("the UI action never settled across the picker overlay")
378 }
379 require.NoError(t, outcome.err)
380 require.Equal(t, ports.UIActionProcessed, outcome.result.Status)
381 require.Equal(t, ports.UIStatusAttached, outcome.result.Context.Status)
382 require.Equal(t, tt.wantTarget, outcome.result.Context.Route.Target)
383 require.NotContains(t, harness.terminal.written(), "hidden", "suppressed output is never written over the picker")
384 if tt.overlayFirst {
385 require.Equal(t, consumed+1, picker.consumedCount(), "the picker consumed the action's input")
386 }
387 if !tt.wantFence {
388 require.Equal(t, fences, countSent[protocol.UIFence](stream), "a picker-consumed action never fences through the daemon")
389 require.Zero(t, countSent[protocol.Input](stream), "picker input never reaches the session")
390 }
391 if tt.inPlace {
392 require.Equal(t, snapshot.Context.Generation, outcome.result.Context.Generation, "an in-place switch keeps the generation")
393 require.Len(t, harness.service.openedRequests(), 1, "an in-place switch never reconnects")
394 } else if tt.commit {
395 require.Greater(t, outcome.result.Context.Generation, snapshot.Context.Generation, "the swap commits a new generation")
396 opened := harness.service.openedRequests()
397 require.Len(t, opened, 2)
398 require.Equal(t, beta, opened[1].Target)
399 require.Greater(t, opened[1].Stream, picker.lastResolvedStream(),
400 "the swap opens a stream identity allocated at open time, not the one reserved at commit")
401 }
402 })
403 }
404}
405
406// TestPickerCloseWithoutAttachment pins the unattached decision: with nothing
407// to return to, Escape and q keep the picker (and the process) alive; only the
408// explicit Ctrl+C exit ends the run.
409func TestPickerCloseWithoutAttachment(t *testing.T) {
410 tests := []struct {
411 name string
412 op pickerOp
413 wantExit bool
414 }{
415 {name: "escape or q keeps the picker", op: pickerOp{close: true}},
416 {name: "ctrl+c exits explicitly", op: pickerOp{close: true, exit: true}, wantExit: true},
417 }
418 for _, tt := range tests {
419 t.Run(tt.name, func(t *testing.T) {
420 picker := newAttachTestPicker()
421 harness := startAttachHarness(t, picker)
422 picker.recordOp(tt.op, "", ports.BrokerOpenStreamRequest{})
423 if tt.wantExit {
424 require.NoError(t, harness.waitRun(t))
425 return
426 }
427 // Prove the close was consumed, then that the run survived it: a
428 // later commit is still admitted.
429 require.Eventually(t, picker.opTaken, 5*time.Second, time.Millisecond)
430 stream := newSessionTestStream()
431 harness.service.setOpenStream(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
432 return stream, nil
433 })
434 picker.commit(sessionTestRequest(true))
435 awaitStreamHello(t, &sync.Mutex{}, &stream)
436 select {
437 case <-harness.runDone:
438 t.Fatal("a cancel with no attachment ended the process")
439 default:
440 }
441 })
442 }
443}
444
445// moveSnapshot is the two-destination publication a move interaction opens
446// with; both rows authorise a move.
447func moveSnapshot(interaction, revision uint64) protocol.PickerSnapshot {
448 return protocol.PickerSnapshot{
449 InteractionID: interaction, SourceID: "serving", SourceRevision: revision, Status: protocol.PickerSourceOK,
450 Lines: []protocol.PickerLine{
451 {Key: "aa/first", Kind: protocol.PickerLineSession, Label: "first", Focusable: true, Actions: protocol.PickerCanMove},
452 {Key: "bb/second", Kind: protocol.PickerLineSession, Label: "second", Focusable: true, Actions: protocol.PickerCanMove},
453 },
454 Cursor: protocol.PickerCursor{Key: "aa/first", Index: 0},
455 }
456}
457
458func moveOffer(interaction uint64) protocol.PickerOffer {
459 return protocol.PickerOffer{InteractionID: interaction, Intent: protocol.PickerIntentMovePane, Title: " Move "}
460}
461
462// openMovePicker opens one move interaction on a live attachment and waits
463// until its box is drawn.
464func openMovePicker(t *testing.T, harness *attachTestHarness, stream *sessionTestStream, interaction uint64) {
465 t.Helper()
466 stream.deliver(moveOffer(interaction))
467 stream.deliver(moveSnapshot(interaction, 1))
468 require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "second") }, 5*time.Second, time.Millisecond, "the move picker was never drawn")
469}
470
471// TestMovePickerOfferIsPresentedAndAnswered pins PA3: the daemon's move offer
472// opens a working destination picker whose input never reaches the session,
473// and every exit path answers with the typed replies and hands input back.
474func TestMovePickerOfferIsPresentedAndAnswered(t *testing.T) {
475 tests := []struct {
476 name string
477 keys []string
478 check func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream)
479 }{
480 {
481 name: "enter commits the cursor destination",
482 keys: []string{"\x1b[B", "\r"},
483 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
484 selection := awaitSent(t, stream, "PickerSelection", isSent[protocol.PickerSelection]).(protocol.PickerSelection)
485 require.Equal(t, uint64(7), selection.InteractionID)
486 require.Equal(t, "serving", selection.SourceID)
487 require.Equal(t, uint64(1), selection.SourceRevision)
488 require.Equal(t, "bb/second", selection.Key)
489 require.Equal(t, protocol.PickerActionMove, selection.Action)
490 // The daemon closes the interaction; the terminal returns to the
491 // attachment with an authoritative repaint.
492 stream.deliver(protocol.PickerClosed{InteractionID: 7})
493 awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
494 },
495 },
496 {
497 name: "q cancels with a typed close",
498 keys: []string{"q"},
499 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
500 closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
501 require.Equal(t, uint64(7), closed.InteractionID)
502 awaitSent(t, stream, "OutputResetRequest", isSent[protocol.OutputResetRequest])
503 },
504 },
505 {
506 name: "a lone escape cancels once its window elapses",
507 keys: []string{"\x1b"},
508 check: func(t *testing.T, harness *attachTestHarness, stream *sessionTestStream) {
509 // The input path first withholds the Escape as a possible
510 // DECRQM reply prefix; its ambiguity window hands it on.
511 var marker *supervisorTestTimer
512 require.Eventually(t, func() bool {
513 for _, timer := range harness.clock.snapshotTimers() {
514 if timer.delay == paletteMarkerAmbiguityDeadline && !timer.stopped() {
515 marker = timer
516 return true
517 }
518 }
519 return false
520 }, 5*time.Second, time.Millisecond, "the marker ambiguity window was never armed")
521 marker.fire()
522 var escape *supervisorTestTimer
523 require.Eventually(t, func() bool {
524 for _, timer := range harness.clock.snapshotTimers() {
525 if timer.delay == pickerEscapeDeadline && timer != marker && !timer.stopped() {
526 escape = timer
527 return true
528 }
529 }
530 return false
531 }, 5*time.Second, time.Millisecond, "the escape window was never armed")
532 require.Zero(t, countSent[protocol.PickerClose](stream), "a withheld escape must not close early")
533 escape.fire()
534 closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
535 require.Equal(t, uint64(7), closed.InteractionID)
536 },
537 },
538 }
539 for _, tt := range tests {
540 t.Run(tt.name, func(t *testing.T) {
541 picker := newAttachTestPicker()
542 harness := startAttachHarness(t, picker)
543 stream := attachLiveSession(t, harness, picker)
544 openMovePicker(t, harness, stream, 7)
545
546 // Output under the box is applied and acknowledged, never written.
547 acks := countSent[protocol.Ack](stream)
548 stream.deliver(laterOutput(2, 2, "hidden-frame"))
549 require.Eventually(t, func() bool { return countSent[protocol.Ack](stream) > acks }, 5*time.Second, time.Millisecond)
550 require.NotContains(t, harness.terminal.written(), "hidden-frame")
551
552 for _, key := range tt.keys {
553 harness.reader.push([]byte(key))
554 }
555 tt.check(t, harness, stream)
556 require.Zero(t, countSent[protocol.Input](stream), "move picker input must never reach the session")
557
558 // Input is never swallowed after the picker ended: the next key is
559 // session Input again.
560 harness.reader.push([]byte("z"))
561 input := awaitSent(t, stream, "session Input", isSent[protocol.Input]).(protocol.Input)
562 require.Equal(t, []byte("z"), input.Data)
563 require.Equal(t, PresentAttached, harness.sup.State().Presentation)
564 })
565 }
566}
567
568// TestMovePickerOfferDeclinedUnderSessionPicker pins the exit path when the
569// move picker cannot be presented: the daemon's interaction is closed at once
570// instead of swallowing input with nothing on screen.
571func TestMovePickerOfferDeclinedUnderSessionPicker(t *testing.T) {
572 picker := newAttachTestPicker()
573 harness := startAttachHarness(t, picker)
574 stream := attachLiveSession(t, harness, picker)
575 stream.deliver(navigationOffer(1))
576 awaitPresentation(t, harness.sup, PresentAttachedPicker)
577
578 stream.deliver(moveOffer(9))
579 closed := awaitSent(t, stream, "PickerClose", isSent[protocol.PickerClose]).(protocol.PickerClose)
580 require.Equal(t, uint64(9), closed.InteractionID)
581 require.Equal(t, PresentAttachedPicker, harness.sup.State().Presentation)
582}
583
584// TestSupervisorReducerPickerOverlay pins the Attached+Picker transitions and
585// the paint fence: only a committed attachment hosts the overlay, cancel
586// returns to Attached, and a settled attachment falls back to the plain
587// picker.
588func TestSupervisorReducerPickerOverlay(t *testing.T) {
589 tests := []struct {
590 name string
591 from Presentation
592 event supervisorEventKind
593 want Presentation
594 wantPaint bool
595 }{
596 {name: "attached opens the overlay", from: PresentAttached, event: supervisorOverlayOpened, want: PresentAttachedPicker, wantPaint: true},
597 {name: "connecting cannot host the overlay", from: PresentConnecting, event: supervisorOverlayOpened, want: PresentConnecting},
598 {name: "plain picker ignores an overlay request", from: PresentPicker, event: supervisorOverlayOpened, want: PresentPicker, wantPaint: true},
599 {name: "cancel returns to the attachment", from: PresentAttachedPicker, event: supervisorOverlayClosed, want: PresentAttached},
600 {name: "close outside the overlay is inert", from: PresentAttached, event: supervisorOverlayClosed, want: PresentAttached},
601 {name: "attachment end falls back to the picker", from: PresentAttachedPicker, event: supervisorAttachEnded, want: PresentPicker, wantPaint: true},
602 {name: "swap goes through connecting", from: PresentAttachedPicker, event: supervisorAttachBegin, want: PresentConnecting},
603 }
604 for _, tt := range tests {
605 t.Run(tt.name, func(t *testing.T) {
606 state := reduceSupervisor(State{Presentation: tt.from, Connectivity: ConnectivityReady}, supervisorEvent{kind: tt.event})
607 require.Equal(t, tt.want, state.Presentation)
608 require.Equal(t, ConnectivityReady, state.Connectivity)
609 require.Equal(t, tt.wantPaint, PickerPresentation(state))
610 })
611 }
612}
613
614// TestMovePickerDrawsAFloatingModalOverTheSession ports main's modal
615// presentation pin: the move picker is a bordered box over the session, never
616// a full-screen takeover, so it neither clears the screen nor spans it.
617func TestMovePickerDrawsAFloatingModalOverTheSession(t *testing.T) {
618 picker := newAttachTestPicker()
619 harness := startAttachHarness(t, picker)
620 stream := attachLiveSession(t, harness, picker)
621 before := len(harness.terminal.written())
622 openMovePicker(t, harness, stream, 7)
623
624 frame := harness.terminal.written()[before:]
625 require.Contains(t, frame, "first", "the destination rows must be drawn")
626 require.Contains(t, frame, "─", "the modal border must be drawn")
627 require.NotContains(t, frame, "\x1b[2J", "the box must not clear the session under it")
628 require.Contains(t, harness.terminal.written(), "ready", "the session frame stays under the box")
629}
630
631// TestPickerControllerLoneEscapeResolvesOnItsWindow pins that a real Escape
632// keypress (one lone byte) cancels once the disambiguation window elapses on
633// the controller clock, and that a following byte disarms the window instead.
634func TestPickerControllerLoneEscapeResolvesOnItsWindow(t *testing.T) {
635 tests := []struct {
636 name string
637 follow []byte
638 wantClose bool
639 }{
640 {name: "window elapses into Escape", wantClose: true},
641 {name: "a following byte resolves the prefix itself", follow: []byte("[B")},
642 }
643 for _, tt := range tests {
644 t.Run(tt.name, func(t *testing.T) {
645 controller, clock := pickerTestController(t)
646 controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
647
648 require.True(t, controller.ConsumeTerminalRead([]byte("\x1b")))
649 escape := clock.awaitTimer(t)
650 require.Equal(t, pickerEscapeDeadline, escape.delay)
651 if tt.follow != nil {
652 require.True(t, controller.ConsumeTerminalRead(tt.follow))
653 require.Eventually(t, escape.stopped, 5*time.Second, time.Millisecond, "a resolving read must disarm the window")
654 op, _ := controller.TakeOp()
655 require.False(t, op.close)
656 return
657 }
658 escape.fire()
659 select {
660 case <-controller.OpsReady():
661 case <-time.After(5 * time.Second):
662 t.Fatal("the elapsed window never woke the supervisor")
663 }
664 op, _ := controller.TakeOp()
665 require.Equal(t, tt.wantClose, op.close)
666 require.False(t, op.exit, "Escape is a cancel, never the explicit exit")
667 })
668 }
669}
670
671// TestPickerOverlayInPlaceSupersededByDaemonOffer pins that a daemon same-peer
672// offer arriving while a picker in-place switch is pending wins: the picker's
673// switch is dropped, never turned into a reconnect. If the daemon then keeps
674// the source, the source is repainted over the closed picker box.
675func TestPickerOverlayInPlaceSupersededByDaemonOffer(t *testing.T) {
676 beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
677 gamma := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "gamma"}
678 tests := []struct {
679 name string
680 refuseOffer bool
681 wantRepaints int
682 }{
683 {name: "the daemon offer arrives"},
684 {name: "the daemon offer is refused", refuseOffer: true, wantRepaints: 1},
685 }
686 for _, tt := range tests {
687 t.Run(tt.name, func(t *testing.T) {
688 picker := newAttachTestPicker()
689 harness := startAttachHarness(t, picker)
690 publishLiveLocal(harness, beta)
691 first := attachLiveSession(t, harness, picker)
692 first.deliver(navigationOffer(1))
693 awaitPresentation(t, harness.sup, PresentAttachedPicker)
694
695 other := sessionTestRequest(true)
696 other.Target = beta
697 picker.recordOp(pickerOp{commit: true}, "row", other)
698 toBeta := awaitSent(t, first, "SamePeerSwitchRequest", isSent[protocol.SamePeerSwitchRequest]).(protocol.SamePeerSwitchRequest)
699
700 first.deliver(protocol.AttachTarget{Session: gamma.SessionName, Intent: protocol.IntentAttach, ExactTarget: &gamma, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true})
701 var toGamma protocol.SamePeerSwitchRequest
702 require.Eventually(t, func() bool {
703 for _, message := range first.messages() {
704 if request, ok := message.(protocol.SamePeerSwitchRequest); ok && request.Target == gamma {
705 toGamma = request
706 return true
707 }
708 }
709 return false
710 }, 5*time.Second, time.Millisecond)
711 // The daemon refuses the older choice because its own offer is pending.
712 first.deliver(protocol.SamePeerSwitchFailure{RequestID: toBeta.RequestID, Code: protocol.SamePeerSwitchStaleTarget})
713 if tt.refuseOffer {
714 first.deliver(protocol.SamePeerSwitchFailure{RequestID: toGamma.RequestID, Code: protocol.SamePeerSwitchStaleTarget})
715 awaitSent(t, first, "OutputResetRequest", isSent[protocol.OutputResetRequest])
716 } else {
717 destination := laterOutput(2, 2, "\x1b[Hgamma")
718 destination.Context.Route.Target = gamma
719 first.deliver(destination)
720 require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "gamma") }, 5*time.Second, time.Millisecond)
721 }
722
723 require.Len(t, harness.service.openedRequests(), 1, "a superseded in-place switch never reconnects")
724 require.Zero(t, countSent[protocol.Detach](first))
725 require.Equal(t, tt.wantRepaints, countSent[protocol.OutputResetRequest](first))
726 })
727 }
728}
729
730// TestPickerOverlayRepeatInPlaceChoice pins that choosing the destination of
731// an in-flight in-place switch again is a close, not a second switch.
732func TestPickerOverlayRepeatInPlaceChoice(t *testing.T) {
733 beta := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{2}, SessionName: "beta"}
734 picker := newAttachTestPicker()
735 harness := startAttachHarness(t, picker)
736 publishLiveLocal(harness, beta)
737 first := attachLiveSession(t, harness, picker)
738 other := sessionTestRequest(true)
739 other.Target = beta
740 for id := range uint64(2) {
741 first.deliver(navigationOffer(id + 1))
742 awaitPresentation(t, harness.sup, PresentAttachedPicker)
743 picker.recordOp(pickerOp{commit: true}, "row", other)
744 awaitPresentation(t, harness.sup, PresentAttached)
745 }
746 destination := laterOutput(2, 2, "\x1b[Hbeta")
747 destination.Context.Route.Target = beta
748 first.deliver(destination)
749 require.Eventually(t, func() bool { return strings.Contains(harness.terminal.written(), "beta") }, 5*time.Second, time.Millisecond)
750
751 require.Equal(t, 1, countSent[protocol.SamePeerSwitchRequest](first))
752 require.Len(t, harness.service.openedRequests(), 1)
753}