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}