v0.30.0 eb3068f

clipboard_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close