1package clipboard
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/bnema/dumber/internal/domain/entity"
12 clipboardmocks "github.com/bnema/dumber/internal/infrastructure/clipboard/mocks"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15)
16
17type toolkitClipboardRecorder struct {
18 *clipboardmocks.MockToolkitClipboard
19
20 writeTextCalls int
21 readTextCalls int
22 writeImageCalls int
23 text string
24 image entity.ImageData
25 writeTextErr error
26 readTextErr error
27 writeImageErr error
28}
29
30func newToolkitClipboardRecorder(t *testing.T) *toolkitClipboardRecorder {
31 t.Helper()
32
33 recorder := &toolkitClipboardRecorder{MockToolkitClipboard: clipboardmocks.NewMockToolkitClipboard(t)}
34 recorder.EXPECT().WriteText(mock.Anything, mock.Anything).RunAndReturn(recorder.writeText).Maybe()
35 recorder.EXPECT().ReadText(mock.Anything).RunAndReturn(recorder.readText).Maybe()
36 recorder.EXPECT().WriteImage(mock.Anything, mock.Anything).RunAndReturn(recorder.writeImage).Maybe()
37 return recorder
38}
39
40func (r *toolkitClipboardRecorder) writeText(_ context.Context, text string) error {
41 r.writeTextCalls++
42 r.text = text
43 return r.writeTextErr
44}
45
46func (r *toolkitClipboardRecorder) readText(_ context.Context) (string, error) {
47 r.readTextCalls++
48 return r.text, r.readTextErr
49}
50
51func (r *toolkitClipboardRecorder) writeImage(_ context.Context, image entity.ImageData) error {
52 r.writeImageCalls++
53 r.image = image
54 return r.writeImageErr
55}
56
57func TestAdapter_ReadTextFallsBackToToolkitClipboardWhenNoSystemTool(t *testing.T) {
58 toolkit := newToolkitClipboardRecorder(t)
59 toolkit.text = "toolkit text"
60 adapter := &Adapter{toolkit: toolkit}
61
62 text, err := adapter.ReadText(context.Background())
63
64 require.NoError(t, err)
65 require.Equal(t, "toolkit text", text)
66 require.Equal(t, 1, toolkit.readTextCalls)
67}
68
69func TestAdapter_HasTextReturnsTrueWhenToolkitFallbackHasText(t *testing.T) {
70 toolkit := newToolkitClipboardRecorder(t)
71 toolkit.text = "toolkit text"
72 adapter := &Adapter{toolkit: toolkit}
73
74 hasText, err := adapter.HasText(context.Background())
75
76 require.NoError(t, err)
77 require.True(t, hasText)
78 require.Equal(t, 1, toolkit.readTextCalls)
79}
80
81func TestAdapter_WriteTextReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
82 sentinel := errors.New("toolkit text failed")
83 toolkit := newToolkitClipboardRecorder(t)
84 toolkit.writeTextErr = sentinel
85 adapter := &Adapter{toolkit: toolkit}
86
87 err := adapter.WriteText(context.Background(), "hello")
88
89 require.Equal(t, sentinel, err)
90 require.Equal(t, 1, toolkit.writeTextCalls)
91 require.Equal(t, "hello", toolkit.text)
92}
93
94func TestAdapter_WriteImageReturnsToolkitErrorWhenNoSystemTool(t *testing.T) {
95 sentinel := errors.New("toolkit image failed")
96 toolkit := newToolkitClipboardRecorder(t)
97 toolkit.writeImageErr = sentinel
98 adapter := &Adapter{toolkit: toolkit}
99
100 err := adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"})
101
102 require.Equal(t, sentinel, err)
103 require.Equal(t, 1, toolkit.writeImageCalls)
104 require.Equal(t, entity.ImageData{Bytes: []byte{1}, MimeType: "image/png"}, toolkit.image)
105}
106
107func TestNew_FallsBackToToolkitClipboardWhenSystemToolsUnavailable(t *testing.T) {
108 oldToolkitFactory := newToolkitClipboard
109 oldLookPath := lookPath
110 t.Cleanup(func() {
111 newToolkitClipboard = oldToolkitFactory
112 lookPath = oldLookPath
113 })
114
115 toolkit := newToolkitClipboardRecorder(t)
116 newToolkitClipboard = func() toolkitClipboard {
117 return toolkit
118 }
119 lookPath = func(_ string) (string, error) {
120 return "", errors.New("missing")
121 }
122
123 t.Setenv("WAYLAND_DISPLAY", "wayland-0")
124 t.Setenv("DISPLAY", ":0")
125
126 adapter := New().(*Adapter)
127 require.NoError(t, adapter.WriteImage(context.Background(), entity.ImageData{Bytes: []byte{1, 2, 3}}))
128 require.Equal(t, 1, toolkit.writeImageCalls)
129 require.Equal(t, []byte{1, 2, 3}, toolkit.image.Bytes)
130}
131
132func TestAdapter_WriteImagePrefersToolkitClipboardWhenAvailable(t *testing.T) {
133 oldToolkitFactory := newToolkitClipboard
134 t.Cleanup(func() {
135 newToolkitClipboard = oldToolkitFactory
136 })
137
138 dir := t.TempDir()
139 scriptPath := filepath.Join(dir, "wl-copy")
140 stdinPath := filepath.Join(dir, "stdin.bin")
141 script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
142 require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
143
144 toolkit := newToolkitClipboardRecorder(t)
145 newToolkitClipboard = func() toolkitClipboard { return toolkit }
146 adapter := &Adapter{copyCmd: scriptPath}
147 image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
148
149 require.NoError(t, adapter.WriteImage(context.Background(), image))
150 require.Equal(t, 1, toolkit.writeImageCalls)
151 require.Equal(t, image, toolkit.image)
152
153 _, err := os.Stat(stdinPath)
154 require.Error(t, err)
155 require.True(t, os.IsNotExist(err))
156}
157
158func TestAdapter_WriteImageFallsBackToSystemClipboardWhenToolkitUnavailable(t *testing.T) {
159 dir := t.TempDir()
160 scriptPath := filepath.Join(dir, "wl-copy")
161 stdinPath := filepath.Join(dir, "stdin.bin")
162 script := "#!/bin/sh\ncat > \"" + stdinPath + "\"\n"
163 require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
164
165 adapter := &Adapter{copyCmd: scriptPath}
166 image := entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/png"}
167
168 require.NoError(t, adapter.WriteImage(context.Background(), image))
169
170 stdin, err := os.ReadFile(stdinPath)
171 require.NoError(t, err)
172 require.Equal(t, image.Bytes, stdin)
173}
174
175func TestNew_FallsBackWaylandBeforeX11(t *testing.T) {
176 oldToolkitFactory := newToolkitClipboard
177 oldLookPath := lookPath
178 t.Cleanup(func() {
179 newToolkitClipboard = oldToolkitFactory
180 lookPath = oldLookPath
181 })
182
183 newToolkitClipboard = func() toolkitClipboard { return nil }
184 lookups := make([]string, 0, 2)
185 lookPath = func(name string) (string, error) {
186 lookups = append(lookups, name)
187 switch name {
188 case "wl-copy":
189 return "", errors.New("wl-copy missing")
190 case "xclip":
191 return "/usr/bin/xclip", nil
192 default:
193 return "", errors.New("unexpected lookPath call")
194 }
195 }
196
197 t.Setenv("WAYLAND_DISPLAY", "wayland-0")
198 t.Setenv("DISPLAY", ":0")
199
200 adapter := New().(*Adapter)
201 require.Equal(t, "/usr/bin/xclip", adapter.copyCmd)
202 require.Equal(t, "/usr/bin/xclip", adapter.pasteCmd)
203 require.Equal(t, []string{"wl-copy", "xclip"}, lookups)
204}
205
206func TestAdapter_WriteImageRejectsEmptyBytes(t *testing.T) {
207 oldToolkitFactory := newToolkitClipboard
208 oldLookPath := lookPath
209 t.Cleanup(func() {
210 newToolkitClipboard = oldToolkitFactory
211 lookPath = oldLookPath
212 })
213
214 toolkit := newToolkitClipboardRecorder(t)
215 newToolkitClipboard = func() toolkitClipboard { return toolkit }
216 lookPath = func(_ string) (string, error) { return "/usr/bin/clipboard", nil }
217
218 err := New().(*Adapter).WriteImage(context.Background(), entity.ImageData{})
219 require.Error(t, err)
220 require.Contains(t, err.Error(), "empty image data")
221 require.Zero(t, toolkit.writeImageCalls)
222}
223
224func TestAdapter_WriteImageWithCommandUsesImageMimeType(t *testing.T) {
225 testCases := []struct {
226 name string
227 image entity.ImageData
228 expected string
229 }{
230 {
231 name: "uses provided MIME type",
232 image: entity.ImageData{Bytes: []byte{1, 2, 3}, MimeType: "image/jpeg"},
233 expected: "image/jpeg",
234 },
235 {
236 name: "falls back to PNG",
237 image: entity.ImageData{Bytes: []byte{1, 2, 3}},
238 expected: "image/png",
239 },
240 }
241
242 for _, tt := range testCases {
243 t.Run(tt.name, func(t *testing.T) {
244 dir := t.TempDir()
245 scriptPath := filepath.Join(dir, "wl-copy")
246 argsPath := filepath.Join(dir, "args.txt")
247 stdinPath := filepath.Join(dir, "stdin.bin")
248 script := "#!/bin/sh\nprintf '%s\n' \"$@\" > \"" + argsPath + "\"\ncat > \"" + stdinPath + "\"\n"
249 require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o755))
250
251 adapter := &Adapter{copyCmd: scriptPath}
252 require.NoError(t, adapter.WriteImage(context.Background(), tt.image))
253
254 args, err := os.ReadFile(argsPath)
255 require.NoError(t, err)
256 require.Equal(t, []string{"--type", tt.expected}, strings.Split(strings.TrimSpace(string(args)), "\n"))
257
258 stdin, err := os.ReadFile(stdinPath)
259 require.NoError(t, err)
260 require.Equal(t, tt.image.Bytes, stdin)
261 })
262 }
263}