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

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