v0.2.1 7e79979

clipboard_test.go RawHistoryFolder

1package clipboard
2
3import (
4	"context"
5	"errors"
6	"io"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/bnema/nefercap/internal/adapters/png"
14	mocks "github.com/bnema/nefercap/internal/mocks/ports"
15	"github.com/bnema/nefercap/internal/ports"
16	"github.com/stretchr/testify/mock"
17	"github.com/stretchr/testify/require"
18)
19
20var frame = ports.Frame{Pixels: []byte{1, 2, 3, 0}, Width: 1, Height: 1, Stride: 4, Format: ports.XRGB8888}
21
22func TestWriterDestinations(t *testing.T) {
23	for _, both := range []bool{false, true} {
24		t.Run(map[bool]string{false: "clipboard only", true: "file and clipboard"}[both], func(t *testing.T) {
25			dir := t.TempDir()
26			path := ""
27			if both {
28				path = filepath.Join(dir, "shot.png")
29			}
30			clip := mocks.NewMockClipboard(t)
31			var data []byte
32			clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error {
33				var err error
34				data, err = io.ReadAll(r)
35				return err
36			}).Once()
37			w := NewWriter(png.New(), clip)
38			require.NoError(t, w.Save(context.Background(), frame, path))
39			require.Equal(t, both, w.Saved)
40			require.True(t, strings.HasPrefix(string(data), "\x89PNG"))
41			if both {
42				disk, err := os.ReadFile(path)
43				require.NoError(t, err)
44				require.Equal(t, disk, data)
45			} else {
46				files, err := os.ReadDir(dir)
47				require.NoError(t, err)
48				require.Empty(t, files)
49			}
50		})
51	}
52}
53
54func TestSavedFileSurvivesClipboardFailureAndReplacement(t *testing.T) {
55	path := filepath.Join(t.TempDir(), "shot.png")
56	clip := mocks.NewMockClipboard(t)
57	failure := errors.New("clipboard unavailable")
58	clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error {
59		require.NoError(t, os.Rename(path, path+".saved"))
60		require.NoError(t, os.Symlink(path+".replacement", path))
61		data, err := io.ReadAll(r)
62		require.NoError(t, err)
63		require.True(t, strings.HasPrefix(string(data), "\x89PNG"))
64		return failure
65	}).Once()
66	w := NewWriter(png.New(), clip)
67	require.ErrorIs(t, w.Save(context.Background(), frame, path), failure)
68	require.True(t, w.Saved)
69	disk, err := os.ReadFile(path + ".saved")
70	require.NoError(t, err)
71	require.True(t, strings.HasPrefix(string(disk), "\x89PNG"))
72}
73
74func TestWriterRejectsBeforeClipboard(t *testing.T) {
75	clip := mocks.NewMockClipboard(t)
76	w := NewWriter(png.New(), clip)
77	ctx, cancel := context.WithCancel(context.Background())
78	cancel()
79	path := filepath.Join(t.TempDir(), "shot.png")
80	require.ErrorIs(t, w.Save(ctx, frame, path), context.Canceled)
81	require.Error(t, w.Save(context.Background(), ports.Frame{}, path))
82	require.NoFileExists(t, path)
83	require.NoError(t, os.WriteFile(path, []byte("keep"), 0600))
84	require.ErrorIs(t, w.Save(context.Background(), frame, path), ports.ErrPathExists)
85	disk, err := os.ReadFile(path)
86	require.NoError(t, err)
87	require.Equal(t, "keep", string(disk))
88}
89
90func TestEncodeFailureAndEarlyClipboardExit(t *testing.T) {
91	for _, path := range []string{"", filepath.Join(t.TempDir(), "failed.png")} {
92		codec := mocks.NewMockScreenshotWriter(t)
93		clip := mocks.NewMockClipboard(t)
94		failure := errors.New("encode failed")
95		codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).Return(failure).Once()
96		if path == "" {
97			clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, r io.Reader) error { _, err := io.Copy(io.Discard, r); return err }).Once()
98		}
99		w := NewWriter(codec, clip)
100		require.ErrorIs(t, w.Save(context.Background(), frame, path), failure)
101		require.False(t, w.Saved)
102		if path != "" {
103			require.NoFileExists(t, path)
104		}
105	}
106	clip := mocks.NewMockClipboard(t)
107	failure := errors.New("early exit")
108	clip.EXPECT().Copy(mock.Anything, mock.Anything).Return(failure).Once()
109	require.ErrorIs(t, NewWriter(png.New(), clip).Save(context.Background(), frame, ""), failure)
110}
111
112// Real executable fixtures exercise os/exec without a handwritten process fake.
113func TestNativeProcessFailures(t *testing.T) {
114	dir := t.TempDir()
115	t.Setenv("PATH", dir)
116	require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "install wl-clipboard")
117	executable := filepath.Join(dir, "wl-copy")
118	require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nprintf 'no compositor' >&2\nexit 7\n"), 0700))
119	require.ErrorContains(t, New().Copy(context.Background(), strings.NewReader("PNG")), "no compositor")
120	require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\nexec /bin/sleep 10\n"), 0700))
121	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
122	defer cancel()
123	began := time.Now()
124	require.ErrorIs(t, New().Copy(ctx, strings.NewReader("PNG")), context.DeadlineExceeded)
125	require.Less(t, time.Since(began), time.Second)
126}
127
128func TestNativeSuccessfulForkDoesNotWaitForStderrOwner(t *testing.T) {
129	dir := t.TempDir()
130	t.Setenv("PATH", dir)
131	// This is a real child process, retaining only stderr after handoff.
132	executable := filepath.Join(dir, "wl-copy")
133	require.NoError(t, os.WriteFile(executable, []byte("#!/bin/sh\n/bin/cat >/dev/null\n/bin/sleep 1 >/dev/null 2>&2 &\nexit 0\n"), 0o700))
134	began := time.Now()
135	require.NoError(t, New().Copy(context.Background(), strings.NewReader("PNG")))
136	require.Less(t, time.Since(began), 500*time.Millisecond)
137}
138
139func TestCopyCancellationJoinsEncoder(t *testing.T) {
140	codec := mocks.NewMockScreenshotWriter(t)
141	clip := mocks.NewMockClipboard(t)
142	started, encoded := make(chan struct{}), make(chan struct{})
143	codec.EXPECT().Encode(mock.Anything, mock.Anything, frame).RunAndReturn(func(_ context.Context, out io.Writer, _ ports.Frame) error {
144		close(started)
145		_, err := out.Write([]byte("PNG")) // backpressure until the reader is closed
146		close(encoded)
147		return err
148	}).Once()
149	clip.EXPECT().Copy(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ io.Reader) error {
150		<-ctx.Done()
151		return ctx.Err()
152	}).Once()
153	ctx, cancel := context.WithCancel(context.Background())
154	defer cancel()
155	done := make(chan error, 1)
156	go func() { done <- NewWriter(codec, clip).Save(ctx, frame, "") }()
157	select {
158	case <-started:
159	case <-time.After(time.Second):
160		t.Fatal("encoder did not start")
161	}
162	cancel()
163	select {
164	case err := <-done:
165		require.ErrorIs(t, err, context.Canceled)
166	case <-time.After(time.Second):
167		t.Fatal("clipboard cancellation did not terminate encoding")
168	}
169	select {
170	case <-encoded:
171	default:
172		t.Fatal("Save returned before the encoder released its borrowed frame")
173	}
174}
175
176func TestStderrBound(t *testing.T) {
177	b := &limitedError{}
178	n, err := b.Write(make([]byte, 1<<20))
179	require.NoError(t, err)
180	require.Equal(t, 1<<20, n)
181	require.Len(t, b.data, 4096)
182}
183
184func BenchmarkClipboardEncode(b *testing.B) {
185	f := ports.Frame{Pixels: make([]byte, 1920*1080*4), Width: 1920, Height: 1080, Stride: 1920 * 4, Format: ports.XRGB8888}
186	b.ReportAllocs()
187	for b.Loop() {
188		if err := png.New().Encode(context.Background(), io.Discard, f); err != nil {
189			b.Fatal(err)
190		}
191	}
192}

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 2

Tags 9

↑↓ moveenter openesc close