1package client_test
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/client"
18)
19
20// clipboardRunTestDeps wires a Run() attempt with an input reader that
21// delivers data then blocks (so the pump stays parked until detach), a
22// transport that captures every MsgInput/MsgImagePush send, and a Recv that
23// answers Welcome then, once triggered, Detached — mirroring the pattern used
24// by the OSC-color-response test elsewhere in this package.
25func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReader, stdin []byte, triggerByte byte) (gotInput chan []byte, gotImage chan ports.ImagePush) {
26 t.Helper()
27 var out bytes.Buffer
28 var restoreCount int32
29 resizeCh := make(chan domain.Size)
30 input := newOneShotBlockingReader(stdin)
31 defer input.unblock()
32
33 tm := portsmocks.NewMockTerminal(t)
34 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
35 tm.EXPECT().EnterRaw().Return(func() error { restoreCount++; return nil }, nil).Once()
36 tm.EXPECT().In().Return(input).Maybe()
37 tm.EXPECT().Out().Return(&out).Maybe()
38 tm.EXPECT().Flush().Return(nil).Maybe()
39 tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
40
41 gotInput = make(chan []byte, 8)
42 gotImage = make(chan ports.ImagePush, 1)
43 allowDetach := make(chan struct{})
44
45 tr := portsmocks.NewMockTransport(t)
46 tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
47 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
48 tr.EXPECT().Send(isType(ports.MsgResize)).Return(nil).Maybe()
49 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
50 in, err := ports.UnmarshalInput(f.Payload)
51 require.NoError(t, err)
52 gotInput <- append([]byte(nil), in.Data...)
53 if bytes.IndexByte(in.Data, triggerByte) >= 0 {
54 closeOnce(allowDetach)
55 }
56 return nil
57 }).Maybe()
58 tr.EXPECT().Send(isType(ports.MsgImagePush)).RunAndReturn(func(f ports.Frame) error {
59 ip, err := ports.UnmarshalImagePush(f.Payload)
60 require.NoError(t, err)
61 gotImage <- ip
62 return nil
63 }).Maybe()
64 tr.EXPECT().Send(isType(ports.MsgClientNotice)).Return(nil).Maybe()
65
66 welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
67 detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
68 recvCh := make(chan recvItem, 1)
69 recvCh <- recvItem{f: welcome}
70 closed := make(chan struct{})
71 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
72 select {
73 case it := <-recvCh:
74 return it.f, it.err
75 case <-allowDetach:
76 select {
77 case <-closed:
78 return ports.Frame{}, io.EOF
79 default:
80 close(closed)
81 return detached, nil
82 }
83 case <-closed:
84 return ports.Frame{}, io.EOF
85 }
86 }).Maybe()
87 tr.EXPECT().Close().Return(nil).Once()
88
89 d := portsmocks.NewMockDialer(t)
90 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
91
92 err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, clip, nil), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: remote})
93 require.NoError(t, err)
94 return gotInput, gotImage
95}
96
97func closeOnce(ch chan struct{}) {
98 select {
99 case <-ch:
100 default:
101 close(ch)
102 }
103}
104
105func drainInput(gotInput chan []byte) []byte {
106 var b []byte
107 for {
108 select {
109 case chunk := <-gotInput:
110 b = append(b, chunk...)
111 default:
112 return b
113 }
114 }
115}
116
117func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testing.T) {
118 clip := portsmocks.NewMockClipboardReader(t)
119 clip.EXPECT().ReadImage(mock.Anything).Return("image/png", []byte("PNGDATA"), nil).Once()
120
121 gotInput, gotImage := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
122
123 select {
124 case ip := <-gotImage:
125 require.Equal(t, uint64(2), ip.InputSeq)
126 require.Equal(t, "image/png", ip.Mime)
127 require.Equal(t, []byte("PNGDATA"), ip.Data)
128 case <-time.After(2 * time.Second):
129 t.Fatal("MsgImagePush was not sent")
130 }
131 require.Equal(t, []byte("ab"), drainInput(gotInput), "Ctrl+V must not be forwarded as input when an image is sent")
132}
133
134func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
135 clip := portsmocks.NewMockClipboardReader(t)
136 clip.EXPECT().ReadImage(mock.Anything).Return("", nil, ports.ErrNoClipboardImage).Once()
137
138 gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
139
140 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "Ctrl+V must be forwarded verbatim when the clipboard has no image")
141}
142
143type clipboardToastLifecycleTransport struct {
144 recv chan ports.Frame
145 sends chan ports.Frame
146}
147
148func (t *clipboardToastLifecycleTransport) Send(f ports.Frame) error {
149 t.sends <- f
150 return nil
151}
152
153func (t *clipboardToastLifecycleTransport) Recv() (ports.Frame, error) { return <-t.recv, nil }
154func (*clipboardToastLifecycleTransport) Close() error { return nil }
155
156type clipboardToastLifecycleDialer struct{ transport ports.Transport }
157
158func (d clipboardToastLifecycleDialer) Dial(context.Context) (ports.Transport, error) {
159 return d.transport, nil
160}
161
162func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testing.T) {
163 var out bytes.Buffer
164 resizeEvents := make(chan domain.Size)
165 input := newOneShotBlockingReader([]byte{0x16})
166 defer input.unblock()
167 term := portsmocks.NewMockTerminal(t)
168 term.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Maybe()
169 term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Once()
170 term.EXPECT().In().Return(input).Maybe()
171 term.EXPECT().Out().Return(&out).Maybe()
172 term.EXPECT().Flush().Return(nil).Maybe()
173 term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(resizeEvents)).Maybe()
174
175 clipboard := portsmocks.NewMockClipboardReader(t)
176 clipboard.EXPECT().ReadImage(mock.Anything).Return("", nil, errors.New("read failed")).Once()
177 transport := &clipboardToastLifecycleTransport{recv: make(chan ports.Frame, 8), sends: make(chan ports.Frame, 16)}
178 transport.recv <- frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
179
180 result := make(chan error, 1)
181 go func() {
182 result <- client.NewRunner(client.Dependencies{Dialer: clipboardToastLifecycleDialer{transport: transport}, Terminal: term, Clock: realClock{}, Clipboard: clipboard}).Run(context.Background(), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: true})
183 }()
184
185 var gotNotice bool
186 for !gotNotice {
187 select {
188 case sent := <-transport.sends:
189 if sent.Type != ports.MsgClientNotice {
190 continue
191 }
192 notice, err := ports.UnmarshalClientNotice(sent.Payload)
193 require.NoError(t, err)
194 require.Equal(t, ports.ClientNoticeClipboardFallback, notice.Action)
195 gotNotice = true
196 case <-time.After(time.Second):
197 t.Fatal("client did not notify daemon of clipboard failure")
198 }
199 }
200 beforeOutput := out.String()
201 transport.recv <- frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("incremental")}))
202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
203
204 select {
205 case err := <-result:
206 require.NoError(t, err)
207 case <-time.After(time.Second):
208 t.Fatal("client did not finish")
209 }
210 require.Equal(t, beforeOutput+"incremental", out.String(), "daemon output must remain verbatim with no local toast bytes")
211}
212
213func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
214 huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
215 clip := portsmocks.NewMockClipboardReader(t)
216 clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
217
218 gotInput, _ := runRemoteClipboardTest(t, true, clip, []byte("a\x16b"), 'b')
219
220 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "an oversized clipboard image must not be swallowed: Ctrl+V is forwarded instead")
221}
222
223func TestRunRemoteClipboardCtrlVInsideBracketedPasteIsNotIntercepted(t *testing.T) {
224 // A MockClipboardReader with no .EXPECT() set fails the test if
225 // ReadImage is ever called — which must never happen for a 0x16 that
226 // arrives as part of pasted content, even when the whole paste (open
227 // marker, content, close marker) lands in a single read alongside it.
228 clip := portsmocks.NewMockClipboardReader(t)
229
230 paste := []byte("\x1b[200~pa\x16ste\x1b[201~Z")
231 gotInput, gotImage := runRemoteClipboardTest(t, true, clip, paste, 'Z')
232
233 require.Equal(t, paste, drainInput(gotInput), "a Ctrl+V byte inside a bracketed paste must be forwarded as ordinary paste content, unsplit")
234 select {
235 case ip := <-gotImage:
236 t.Fatalf("clipboard image must not be pushed for a Ctrl+V inside a paste, got %+v", ip)
237 default:
238 }
239}
240
241func TestRunLocalAttachDoesNotInterceptCtrlVEvenIfClipboardConfigured(t *testing.T) {
242 // A MockClipboardReader with no .EXPECT() set fails the test if ReadImage
243 // is ever called, which is exactly what a local (non-remote) attach must
244 // never do.
245 clip := portsmocks.NewMockClipboardReader(t)
246
247 gotInput, _ := runRemoteClipboardTest(t, false, clip, []byte("a\x16b"), 'b')
248
249 require.Equal(t, []byte("a\x16b"), drainInput(gotInput), "local attach must forward Ctrl+V untouched")
250}