attachment_input_test.go RawHistoryFolder

1package client
2
3import (
4	"context"
5	"io"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	renderer "github.com/bnema/vev-vt"
13	"github.com/bnema/vev/internal/ports"
14	portsmocks "github.com/bnema/vev/internal/ports/mocks"
15	"github.com/bnema/vev/internal/protocol"
16	"github.com/stretchr/testify/mock"
17)
18
19// Ported from the old client's palette_attach_generation_test.go and paste
20// tests: the attached worker now owns terminal input demultiplexing.
21
22// inputTestReader hands each queued chunk to exactly one terminal Read.
23type inputTestReader struct{ chunks chan []byte }
24
25func (r *inputTestReader) Read(p []byte) (int, error) {
26	data, ok := <-r.chunks
27	if !ok {
28		return 0, io.EOF
29	}
30	return copy(p, data), nil
31}
32
33type inputHarness struct {
34	stream *sessionTestStream
35	term   *workerTestTerminal
36	clock  *supervisorTestClock
37	reader *inputTestReader
38	events chan AttachmentEvent
39}
40
41// startInputHarness runs one real worker through the real foreground host and
42// terminal input pump until its initial publication is committed. The worker
43// clock is separate from the host clock, so every timer on it belongs to the
44// input path.
45func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
46	return startInputHarnessWithConfig(t, themes, sessionTestRequest(true), nil)
47}
48
49func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader) *inputHarness {
50	t.Helper()
51	reader := &inputTestReader{chunks: make(chan []byte, 16)}
52	pump := newTerminalInputPump(reader)
53	pump.start()
54	t.Cleanup(func() {
55		pump.stop()
56		close(reader.chunks)
57	})
58	h := &inputHarness{
59		stream: newSessionTestStream(),
60		term:   newWorkerTestTerminal(),
61		clock:  newSupervisorTestClock(),
62		reader: reader,
63		events: make(chan AttachmentEvent, 1),
64	}
65	host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump})
66	cfg := sessionTestWorkerConfig(request)
67	cfg.Clock = h.clock
68	cfg.Theme = themes
69	cfg.Clipboard = clipboard
70	cfg.TrueColor = true
71	worker, err := newSessionAttachmentWorker(cfg)
72	require.NoError(t, err)
73	go func() {
74		event, _ := host.Run(context.Background(), AttachmentToken{Generation: 4, Attempt: 1}, worker, h.stream)
75		h.events <- event
76	}()
77	awaitHello(t, h.stream)
78	h.stream.deliver(protocol.Welcome{SessionName: "alpha"})
79	h.stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
80	return h
81}
82
83func (h *inputHarness) send(data string) { h.reader.chunks <- []byte(data) }
84
85func (h *inputHarness) end(t *testing.T) {
86	t.Helper()
87	h.stream.deliver(protocol.Detached{Reason: protocol.ReasonDetach})
88	select {
89	case event := <-h.events:
90		require.Equal(t, AttachmentEventEnded, event.Kind)
91	case <-time.After(5 * time.Second):
92		t.Fatal("the worker never settled")
93	}
94}
95
96func (h *inputHarness) themes() []protocol.Theme {
97	var themes []protocol.Theme
98	for _, message := range h.stream.messages() {
99		if theme, ok := message.(protocol.Theme); ok {
100			themes = append(themes, theme)
101		}
102	}
103	return themes
104}
105
106func (h *inputHarness) inputs() []string {
107	var inputs []string
108	for _, message := range h.stream.messages() {
109		if input, ok := message.(protocol.Input); ok {
110			inputs = append(inputs, string(input.Data))
111		}
112	}
113	return inputs
114}
115
116// awaitThemes waits for the nth Theme and returns every Theme sent so far.
117func (h *inputHarness) awaitThemes(t *testing.T, n int) []protocol.Theme {
118	t.Helper()
119	require.Eventually(t, func() bool { return len(h.themes()) >= n }, 5*time.Second, time.Millisecond, "worker never sent Theme #%d", n)
120	return h.themes()
121}
122
123func (h *inputHarness) awaitInputs(t *testing.T, n int) []string {
124	t.Helper()
125	require.Eventually(t, func() bool { return len(h.inputs()) >= n }, 5*time.Second, time.Millisecond, "worker never sent Input #%d", n)
126	return h.inputs()
127}
128
129// awaitQueries waits until query was written n times after the frame.
130func (h *inputHarness) awaitQueries(t *testing.T, query string, n int) {
131	t.Helper()
132	require.Eventually(t, func() bool { return strings.Count(h.term.written(), query) >= n }, 5*time.Second, time.Millisecond, "worker never wrote query #%d %q", n, query)
133}
134
135func TestAttachmentInputRemoteClipboardImageAndFallback(t *testing.T) {
136	for _, tt := range []struct {
137		name string
138		mime string
139		data []byte
140		err  error
141		want protocol.ClientMessage
142	}{
143		{name: "image", mime: "image/png", data: []byte{1, 2}, want: protocol.ImagePush{Mime: "image/png", Data: []byte{1, 2}}},
144		{name: "no image forwards key", err: ports.ErrNoClipboardImage, want: protocol.Input{Data: []byte{ctrlV}}},
145		{name: "oversized image forwards key", mime: "image/png", data: make([]byte, maxClipboardImagePush+1), want: protocol.Input{Data: []byte{ctrlV}}},
146	} {
147		t.Run(tt.name, func(t *testing.T) {
148			reader := portsmocks.NewMockClipboardReader(t)
149			reader.EXPECT().ReadImage(mock.Anything).Return(tt.mime, tt.data, tt.err).Once()
150			h := startInputHarnessWithConfig(t, nil, sessionTestRequest(false), reader)
151			h.send("before\x16after")
152			require.Eventually(t, func() bool {
153				return len(h.stream.messages()) >= 4
154			}, 5*time.Second, time.Millisecond)
155			h.end(t)
156			var got []protocol.ClientMessage
157			for _, msg := range h.stream.messages() {
158				switch msg.(type) {
159				case protocol.Input, protocol.ImagePush, protocol.ClientNotice:
160					got = append(got, msg)
161				}
162			}
163			require.IsType(t, protocol.Input{}, got[0])
164			require.Equal(t, []byte("before"), got[0].(protocol.Input).Data)
165			if tt.name == "oversized image forwards key" {
166				require.Equal(t, protocol.ClientNotice{Action: protocol.ClientNoticeClipboardTooLarge}, got[1])
167				got = append(got[:1], got[2:]...)
168			}
169			switch want := tt.want.(type) {
170			case protocol.Input:
171				require.Equal(t, want.Data, got[1].(protocol.Input).Data)
172			case protocol.ImagePush:
173				require.Equal(t, want.Mime, got[1].(protocol.ImagePush).Mime)
174				require.Equal(t, want.Data, got[1].(protocol.ImagePush).Data)
175			}
176			require.Equal(t, []byte("after"), got[2].(protocol.Input).Data)
177		})
178	}
179}
180
181func paletteReply(foreground, background string, slot uint8, color string) string {
182	return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
183}
184
185func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
186	themes := &terminalThemeState{}
187	first := startInputHarness(t, themes)
188	first.awaitQueries(t, paletteColorBatch, 1)
189	require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")
190	cleared := first.awaitThemes(t, 1)[0]
191	require.True(t, cleared.TrueColor)
192	require.Zero(t, cleared.PaletteKnown)
193	require.False(t, cleared.HasForeground)
194
195	first.send(paletteReply("#010203", "#040506", 2, "#102030"))
196	final := first.awaitThemes(t, 2)[1]
197	require.True(t, final.HasForeground)
198	require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
199	require.True(t, final.HasBackground)
200	require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, final.Background)
201	require.Equal(t, uint16(1<<2), final.PaletteKnown)
202	require.Equal(t, renderer.RGB{R: 16, G: 32, B: 48}, final.Palette[2])
203	first.end(t)
204	require.Empty(t, first.inputs(), "terminal replies never reach the session")
205
206	// A replacement attachment clears only the palette: the retained default
207	// colors avoid a neutral flash, and no restore Theme precedes it.
208	second := startInputHarness(t, themes)
209	second.awaitQueries(t, paletteColorBatch, 1)
210	cleared = second.awaitThemes(t, 1)[0]
211	require.True(t, cleared.HasForeground)
212	require.Equal(t, final.Foreground, cleared.Foreground)
213	require.Equal(t, final.Background, cleared.Background)
214	require.Zero(t, cleared.PaletteKnown)
215	require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
216	second.send(paletteReply("#111213", "#141516", 2, "#202122"))
217	second.awaitThemes(t, 2)
218	second.end(t)
219	require.Len(t, second.themes(), 2)
220}
221
222func TestAttachmentInputSchemeNotificationReplacesPalette(t *testing.T) {
223	h := startInputHarness(t, &terminalThemeState{})
224	h.awaitQueries(t, paletteColorBatch, 1)
225	_ = h.clock.awaitTimer(t) // initial completion deadline, cancelled by the marker
226	h.send(paletteReply("#010203", "#040506", 2, "#102030"))
227	h.awaitThemes(t, 2)
228
229	h.send("\x1b[?997;2n")
230	cleared := h.awaitThemes(t, 3)[2]
231	require.True(t, cleared.SchemeKnown)
232	require.True(t, cleared.Light)
233	require.Zero(t, cleared.PaletteKnown)
234	h.awaitQueries(t, paletteBoundaryQuery, 2) // one inside the first batch, then the drain
235	drain := h.clock.awaitTimer(t)
236	require.Equal(t, paletteGenerationDeadline, drain.delay)
237
238	// The drain deadline starts the color batch even though no reply arrives,
239	// and the completion deadline finalizes the partial generation.
240	drain.fire()
241	h.awaitQueries(t, paletteColorBatch, 2)
242	h.clock.awaitTimer(t).fire()
243	final := h.awaitThemes(t, 4)[3]
244	require.True(t, final.SchemeKnown)
245	require.True(t, final.Light)
246	require.False(t, final.HasForeground)
247	require.False(t, final.HasBackground)
248	h.end(t)
249	require.Len(t, h.themes(), 4, "each generation publishes only a cleared then a definitive Theme")
250	require.Empty(t, h.inputs(), "the scheme notification never reaches the session")
251}
252
253func TestAttachmentInputLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
254	h := startInputHarness(t, &terminalThemeState{})
255	h.awaitQueries(t, paletteColorBatch, 1)
256	_ = h.clock.awaitTimer(t)
257	h.send(paletteReply("#010203", "#040506", 2, "#102030"))
258	h.awaitThemes(t, 2)
259
260	h.send("\x1b[?997;1n")
261	h.awaitThemes(t, 3)
262	h.clock.awaitTimer(t).fire() // drain deadline
263	h.awaitQueries(t, paletteColorBatch, 2)
264	_ = h.clock.awaitTimer(t) // completion deadline, cancelled by the second marker
265
266	// The drain's stale reports arrive before its late boundary and must not
267	// contaminate the replacement. Reports after it belong to the batch.
268	h.send("\x1b]10;#a1a2a3\a\x1b]11;#a4a5a6\a\x1b]4;2;#a7a8a9\a\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
269	final := h.awaitThemes(t, 4)[3]
270	require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
271	require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
272	require.Equal(t, uint16(1<<3), final.PaletteKnown)
273	h.end(t)
274}
275
276func TestAttachmentInputForwardsStandaloneEscapeAfterAmbiguityDeadlines(t *testing.T) {
277	h := startInputHarness(t, &terminalThemeState{})
278	h.awaitQueries(t, paletteColorBatch, 1)
279	_ = h.clock.awaitTimer(t) // initial completion deadline
280
281	// A bare Escape may start a DECRQM reply, then a bracketed-paste marker.
282	// Each window elapses on the clock; no later read is needed.
283	h.send("\x1b")
284	marker := h.clock.awaitTimer(t)
285	require.Equal(t, paletteMarkerAmbiguityDeadline, marker.delay)
286	require.Empty(t, h.inputs())
287	marker.fire()
288	paste := h.clock.awaitTimer(t)
289	require.Equal(t, pasteFlushDelay, paste.delay)
290	require.Empty(t, h.inputs())
291	paste.fire()
292	require.Equal(t, []string{"\x1b"}, h.awaitInputs(t, 1))
293	h.end(t)
294}
295
296func TestAttachmentInputConsumesSplitMarkerAndCancelsAmbiguityDeadline(t *testing.T) {
297	h := startInputHarness(t, &terminalThemeState{})
298	h.awaitQueries(t, paletteColorBatch, 1)
299	_ = h.clock.awaitTimer(t)
300	h.awaitThemes(t, 1)
301
302	h.send("before\x1b[?2031;")
303	marker := h.clock.awaitTimer(t)
304	h.send("1$yafter")
305	final := h.awaitThemes(t, 2)[1]
306	require.False(t, final.HasForeground)
307	require.Equal(t, "beforeafter", strings.Join(h.awaitInputs(t, 2), ""))
308	require.True(t, marker.stopped(), "the completed marker cancels its ambiguity deadline")
309	h.end(t)
310}
311
312func TestAttachmentInputPartialOSCPublishesNothingUntilComplete(t *testing.T) {
313	h := startInputHarness(t, &terminalThemeState{})
314	h.awaitQueries(t, paletteColorBatch, 1)
315	h.awaitThemes(t, 1)
316
317	h.send("\x1b]10;#0102")
318	h.send("03\az")
319	require.Equal(t, []string{"z"}, h.awaitInputs(t, 1))
320	require.Len(t, h.themes(), 1, "a partial OSC reply never publishes Theme")
321
322	h.send("\x1b[?2031;1$y")
323	final := h.awaitThemes(t, 2)[1]
324	require.True(t, final.HasForeground)
325	require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
326	h.end(t)
327	require.Equal(t, []string{"z"}, h.inputs())
328}
329
330// TestAttachmentInputFraming covers the session-bound framing without palette
331// detection: replies are stripped, and a bracketed paste split across reads
332// or markers reaches the daemon as one Input.
333func TestAttachmentInputFraming(t *testing.T) {
334	tests := []struct {
335		name  string
336		reads []string
337		want  []string
338	}{
339		{name: "plain keys", reads: []string{"abc"}, want: []string{"abc"}},
340		{name: "mouse report stays intact", reads: []string{"\x1b[<0;1;1M"}, want: []string{"\x1b[<0;1;1M"}},
341		{name: "scheme notification and color reply are stripped", reads: []string{"a\x1b[?997;1nb\x1b]11;rgb:0000/0000/0000\ac"}, want: []string{"abc"}},
342		{name: "paste split across reads", reads: []string{"\x1b[200~hello\n", "world\x1b[201~"}, want: []string{"\x1b[200~hello\nworld\x1b[201~"}},
343		{name: "paste split inside the opening marker", reads: []string{"ab\x1b[20", "0~x\x1b[201~z"}, want: []string{"ab", "\x1b[200~x\x1b[201~", "z"}},
344		{name: "paste split after its escape", reads: []string{"ab\x1b", "[200~x\x1b[201~"}, want: []string{"ab", "\x1b[200~x\x1b[201~"}},
345	}
346	for _, tt := range tests {
347		t.Run(tt.name, func(t *testing.T) {
348			h := startInputHarness(t, nil)
349			for _, read := range tt.reads {
350				h.send(read)
351			}
352			require.Equal(t, tt.want, h.awaitInputs(t, len(tt.want)))
353			h.end(t)
354			require.Equal(t, tt.want, h.inputs())
355			require.Empty(t, h.themes(), "no Theme without a retained theme state")
356			require.Equal(t, "\x1b[Hready", h.term.written(), "no palette query without a retained theme state")
357		})
358	}
359}

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