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}