picker_overlay_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close