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