session_picker_offer_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5	"time"
6
7	"github.com/stretchr/testify/require"
8
9	"github.com/bnema/vev/internal/protocol"
10	"github.com/bnema/vev/internal/protocol/wire"
11)
12
13// awaitSentServerMessage drains captured frames until one satisfies match.
14func awaitSentServerMessage(t *testing.T, sends <-chan wire.Envelope, what string, match func(protocol.ServerMessage) bool) protocol.ServerMessage {
15	t.Helper()
16	deadline := time.After(2 * time.Second)
17	for {
18		select {
19		case frame := <-sends:
20			message := decodeServerMessage(t, frame)
21			if match(message) {
22				return message
23			}
24		case <-deadline:
25			t.Fatalf("daemon sent no %s", what)
26			return nil
27		}
28	}
29}
30
31// TestSessionPickerCommandOffersClientPickerOverLiveAttachment pins PA1 on the
32// daemon side: SSP keeps the attachment, sends a navigation offer the client
33// composes its own picker for, and opens no daemon interaction that could
34// swallow input.
35func TestSessionPickerCommandOffersClientPickerOverLiveAttachment(t *testing.T) {
36	d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
37	defer releaseAll(releases)
38
39	d.handleInput(source, ac, []byte("\x1b "))
40	require.True(t, ac.overlays.paletteActive())
41	d.handleInput(source, ac, []byte("SSP\r"))
42
43	offer := awaitSentServerMessage(t, sends, "navigation PickerOffer", func(message protocol.ServerMessage) bool {
44		_, ok := message.(protocol.PickerOffer)
45		return ok
46	}).(protocol.PickerOffer)
47	require.Equal(t, protocol.PickerIntentNavigation, offer.Intent)
48	require.NotZero(t, offer.InteractionID)
49	require.False(t, ac.overlays.pickerClientActive(), "a navigation offer must not open a daemon interaction")
50	require.Same(t, source, ac.currentAttachmentSession(), "SSP must keep the attachment live")
51	assertNoFurtherFrame(t, sends, "Detached", "SSP must not detach the attachment")
52}
53
54// TestPaletteClientPickerFollowsPaletteErasingFrame pins the ordering the
55// client overlay depends on: it suppresses attachment output once the picker
56// opens, so the frame erasing the palette must reach it before the offer (SSP)
57// or the first move snapshot (MFP), even while output is ACK-blocked.
58func TestPaletteClientPickerFollowsPaletteErasingFrame(t *testing.T) {
59	tests := []struct {
60		name      string
61		command   string
62		saturated bool
63		opens     func(protocol.ServerMessage) bool
64	}{
65		{name: "session picker", command: "SSP", opens: isPickerOffer},
66		{name: "session picker with full output window", command: "SSP", saturated: true, opens: isPickerOffer},
67		{name: "move picker", command: "MFP", opens: isPickerSnapshot},
68		{name: "move picker with full output window", command: "MFP", saturated: true, opens: isPickerSnapshot},
69	}
70	for _, tt := range tests {
71		t.Run(tt.name, func(t *testing.T) {
72			d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
73			defer releaseAll(releases)
74			// Production paints through the coordinator, which retries an
75			// ACK-blocked paint once the client acknowledges.
76			rc := d.attachCoordinator(source, nil, ac, true)
77			t.Cleanup(func() { rc.beginSessionTeardown().finish(); rc.waitForTimerWorkers() })
78			d.handleInput(source, ac, []byte("\x1b "))
79			d.handleInput(source, ac, []byte(tt.command))
80			awaitFrame(t, sends, "Output")
81			var blocking protocol.Output
82			for _, frame := range drainAllFrames(sends) {
83				if output, ok := decodeServerMessage(t, frame).(protocol.Output); ok {
84					blocking = output
85				}
86			}
87			if tt.saturated {
88				// The palette frame is still unacknowledged: a one-frame window
89				// blocks every further paint until the client ACKs it.
90				require.NotZero(t, blocking.New)
91				ac.output.setWindow(1)
92				require.True(t, ac.output.atCapacity())
93			}
94
95			d.handleInput(source, ac, []byte("\r"))
96			require.False(t, ac.overlays.paletteActive())
97			if tt.saturated {
98				// The work runs asynchronously once released, so give a wrong
99				// early release time to reach the wire before asserting.
100				assertNoSentServerMessage(t, sends, 200*time.Millisecond, tt.opens, "picker opened before the palette-erasing frame could be sent")
101				d.handleAttachmentClientMessage(captureAttachmentCapability(source, ac, ac.transport()), protocol.Ack{Epoch: blocking.Epoch, State: blocking.New})
102			}
103
104			first := awaitSentServerMessage(t, sends, "Output or picker", func(message protocol.ServerMessage) bool {
105				_, output := message.(protocol.Output)
106				return output || tt.opens(message)
107			})
108			require.IsType(t, protocol.Output{}, first, "the palette-erasing frame must precede the picker")
109			awaitSentServerMessage(t, sends, "picker open", tt.opens)
110		})
111	}
112}
113
114func isPickerOffer(message protocol.ServerMessage) bool {
115	_, ok := message.(protocol.PickerOffer)
116	return ok
117}
118
119func isPickerSnapshot(message protocol.ServerMessage) bool {
120	_, ok := message.(protocol.PickerSnapshot)
121	return ok
122}
123
124// assertNoSentServerMessage fails if a matching message is sent within wait.
125func assertNoSentServerMessage(t *testing.T, sends <-chan wire.Envelope, wait time.Duration, match func(protocol.ServerMessage) bool, failure string) {
126	t.Helper()
127	deadline := time.After(wait)
128	for {
129		select {
130		case frame := <-sends:
131			require.False(t, match(decodeServerMessage(t, frame)), failure)
132		case <-deadline:
133			return
134		}
135	}
136}
137
138// A scripted CommandRequest has no palette to erase: its refusals stay
139// synchronous command results instead of a false success.
140func TestCommandRequestMovePickerReportsRefusalSynchronously(t *testing.T) {
141	d, source, ac, _, _, releases, sends := setupMovePickerSessionsCore(t, stubClock{}, 0)
142	defer releaseAll(releases)
143	d.mu.Lock()
144	delete(d.sessions, "destination")
145	d.mu.Unlock()
146	token := captureAttachmentCapability(source, ac, ac.transport())
147	require.False(t, d.handleAttachmentClientMessage(token, protocol.CommandRequest{Version: protocol.Version, RequestID: 5, Attached: true, Slug: "move-pane"}))
148	result := awaitSentServerMessage(t, sends, "CommandResult", func(message protocol.ServerMessage) bool {
149		_, ok := message.(protocol.CommandResult)
150		return ok
151	}).(protocol.CommandResult)
152	require.NotEqual(t, protocol.CommandSucceeded, result.Outcome, "a refused move must not report success")
153	require.False(t, ac.overlays.pickerClientActive())
154}

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