clipboard_intercept_internal_test.go RawHistoryFolder
1package client
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/vev/internal/ports"
12)
13
14// ciFakeReader is a directly-controllable ports.ClipboardReader for
15// white-box interceptor tests (this file lives in package client, alongside
16// paste_coalescer_test.go, so it can drive pasteCoalescer.Scan directly).
17type ciFakeReader struct {
18 mime string
19 data []byte
20 err error
21 n int
22}
23
24func (r *ciFakeReader) ReadImage(context.Context) (string, []byte, error) {
25 r.n++
26 return r.mime, r.data, r.err
27}
28
29func newTestClipboardIntercept(reader ports.ClipboardReader) (*clipboardIntercept, *pasteCoalescer, *pcFakeClock, *pcCollector, *[]ports.ImagePush) {
30 clk := &pcFakeClock{}
31 col := &pcCollector{}
32 pc := newPasteCoalescer(clk, col.emit)
33 var images []ports.ImagePush
34 ci := &clipboardIntercept{
35 coalescer: pc,
36 reader: reader,
37 log: slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError + 1})),
38 sendImage: func(mime string, data []byte) {
39 images = append(images, ports.ImagePush{Mime: mime, Data: data})
40 },
41 next: pc.Scan,
42 }
43 return ci, pc, clk, col, &images
44}
45
46func TestClipboardInterceptCtrlVWithImageSendsNoPassthrough(t *testing.T) {
47 reader := &ciFakeReader{mime: "image/png", data: []byte("PNGDATA")}
48 ci, pc, _, col, images := newTestClipboardIntercept(reader)
49 defer pc.Close()
50
51 ci.Scan([]byte("a"))
52 ci.Scan([]byte{ctrlV})
53 ci.Scan([]byte("b"))
54
55 require.Equal(t, 1, reader.n, "clipboard must be read exactly once per Ctrl+V")
56 require.Equal(t, []ports.ImagePush{{Mime: "image/png", Data: []byte("PNGDATA")}}, *images)
57 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, col.snapshot(), "0x16 itself must never reach the coalescer/pane")
58}
59
60func TestClipboardInterceptCtrlVNoImageForwardsByte(t *testing.T) {
61 reader := &ciFakeReader{err: ports.ErrNoClipboardImage}
62 ci, pc, _, col, images := newTestClipboardIntercept(reader)
63 defer pc.Close()
64
65 ci.Scan([]byte("a\x16b"))
66
67 require.Empty(t, *images)
68 require.Equal(t, [][]byte{[]byte("a"), {ctrlV}, []byte("b")}, col.snapshot())
69}
70
71func TestClipboardInterceptCtrlVOtherErrorForwardsByte(t *testing.T) {
72 reader := &ciFakeReader{err: errors.New("boom")}
73 ci, pc, _, col, images := newTestClipboardIntercept(reader)
74 defer pc.Close()
75
76 ci.Scan([]byte{ctrlV})
77
78 require.Empty(t, *images)
79 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
80}
81
82func TestClipboardInterceptFailureNotifiesDaemonAndForwardsCtrlV(t *testing.T) {
83 tests := []struct {
84 name string
85 reader ports.ClipboardReader
86 want uint8
87 }{
88 {name: "image read failure", reader: &ciFakeReader{err: errors.New("boom")}, want: ports.ClientNoticeClipboardFallback},
89 {name: "oversized image", reader: &ciFakeReader{mime: "image/png", data: make([]byte, maxClipboardImagePush+1)}, want: ports.ClientNoticeClipboardTooLarge},
90 }
91 for _, tt := range tests {
92 t.Run(tt.name, func(t *testing.T) {
93 ci, pc, _, col, images := newTestClipboardIntercept(tt.reader)
94 defer pc.Close()
95 var actions []uint8
96 ci.sendNotice = func(action uint8) { actions = append(actions, action) }
97
98 ci.Scan([]byte{ctrlV})
99
100 require.Equal(t, []uint8{tt.want}, actions)
101 require.Empty(t, *images)
102 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot())
103 })
104 }
105}
106
107func TestClipboardInterceptOversizedImageForwardsByteInsteadOfSwallowing(t *testing.T) {
108 huge := make([]byte, maxClipboardImagePush+1)
109 reader := &ciFakeReader{mime: "image/png", data: huge}
110 ci, pc, _, col, images := newTestClipboardIntercept(reader)
111 defer pc.Close()
112
113 ci.Scan([]byte{ctrlV})
114
115 require.Empty(t, *images, "an oversized image must not be sent")
116 require.Equal(t, [][]byte{{ctrlV}}, col.snapshot(), "an oversized image must fall back to forwarding Ctrl+V, not drop it")
117}
118
119func TestClipboardInterceptMultipleCtrlVInOneChunk(t *testing.T) {
120 reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
121 ci, pc, _, col, images := newTestClipboardIntercept(reader)
122 defer pc.Close()
123
124 ci.Scan([]byte("a\x16b\x16c"))
125
126 require.Equal(t, 2, reader.n)
127 require.Len(t, *images, 2)
128 require.Equal(t, [][]byte{[]byte("a"), []byte("b"), []byte("c")}, col.snapshot())
129}
130
131func TestClipboardInterceptCtrlVInsideBracketedPasteSameChunkNotIntercepted(t *testing.T) {
132 reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
133 ci, pc, _, col, images := newTestClipboardIntercept(reader)
134 defer pc.Close()
135
136 paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
137 ci.Scan(paste)
138
139 require.Zero(t, reader.n, "clipboard must never be consulted for a Ctrl+V inside a paste")
140 require.Empty(t, *images)
141 require.Equal(t, [][]byte{paste}, col.snapshot(), "the paste must reach the coalescer whole, byte-identical")
142}
143
144func TestClipboardInterceptCtrlVInsideBracketedPasteAcrossChunksNotIntercepted(t *testing.T) {
145 reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
146 ci, pc, _, col, images := newTestClipboardIntercept(reader)
147 defer pc.Close()
148
149 paste := []byte("\x1b[200~pa\x16ste\x1b[201~")
150 for s := 1; s < len(paste); s++ {
151 t.Run("", func(t *testing.T) {
152 reader.n = 0
153 *images = nil
154 col.emits = nil
155 ci.Scan(paste[:s])
156 ci.Scan(paste[s:])
157 require.Zero(t, reader.n, "split at %d: clipboard must never be consulted for a Ctrl+V inside a paste", s)
158 require.Empty(t, *images)
159 require.Equal(t, [][]byte{paste}, col.snapshot(), "split at %d: the paste must reach the coalescer whole", s)
160 })
161 }
162}
163
164func TestClipboardInterceptCtrlVAfterLoneEscapePendingNotIntercepted(t *testing.T) {
165 // A trailing lone ESC (a strict prefix of the open marker) is held by
166 // the coalescer as "pending" without yet flipping Buffering() via the
167 // old (buffering-only) definition; Buffering() must also cover pending
168 // so a Ctrl+V arriving before the flush timer fires is never
169 // misinterpreted mid-marker.
170 reader := &ciFakeReader{mime: "image/png", data: []byte("X")}
171 ci, pc, _, col, images := newTestClipboardIntercept(reader)
172 defer pc.Close()
173
174 ci.Scan([]byte{0x1b}) // held as pending, awaiting more marker bytes
175 require.True(t, pc.Buffering(), "a pending trailing marker-prefix must count as Buffering")
176
177 ci.Scan([]byte{ctrlV})
178
179 require.Zero(t, reader.n, "clipboard must not be consulted while a marker prefix is pending")
180 require.Empty(t, *images)
181 // The ESC-then-Ctrl+V recombine as ordinary bytes handed whole to the
182 // coalescer, which does not find a marker in "\x1bfamiliar" and emits
183 // it as plain text once no further marker bytes follow.
184 require.Equal(t, [][]byte{{0x1b, ctrlV}}, col.snapshot())
185}