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}