1package screencast
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "slices"
8 "strings"
9 "testing"
10
11 "github.com/bnema/zerobus"
12 "github.com/stretchr/testify/mock"
13)
14
15// writeShot is what nefercap does: it writes the PNG named by -file.
16func writeShot(t *testing.T, args []string) {
17 t.Helper()
18 i := slices.Index(args, "-file")
19 if i < 0 || i+1 >= len(args) {
20 t.Errorf("no -file in %v", args)
21 return
22 }
23 if err := os.WriteFile(args[i+1], []byte("\x89PNG"), 0o600); err != nil {
24 t.Error(err)
25 }
26}
27
28// Screenshot: nefercap on the focused output; the reply carries the file.
29func TestScreenshotRunsNefercapOnFocusedOutput(t *testing.T) {
30 rt := t.TempDir()
31 t.Setenv("XDG_RUNTIME_DIR", rt)
32 h, c := ready(t, harnessOpts{})
33 got := make(chan []string, 1)
34 h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
35 got <- slices.Clone(args)
36 writeShot(t, args)
37 return nil
38 }).Once()
39 name, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) {
40 e.Bool(true)
41 e.Bool(true)
42 e.Str("ignored")
43 })
44 if name != "" {
45 t.Fatal(name)
46 }
47 r := m.Body()
48 ok, file := r.Bool(), strings.Clone(r.Str())
49 args := <-got
50 if !ok || !strings.HasPrefix(file, filepath.Join(rt, "neferwl", "screenshot-")) || !strings.HasSuffix(file, ".png") {
51 t.Fatalf("reply %v %q", ok, file)
52 }
53 want := []string{"screenshot", "-output", "HEADLESS-1", "-file", file}
54 if !slices.Equal(args, want) {
55 t.Fatalf("args %v, want %v", args, want)
56 }
57 if fi, err := os.Stat(filepath.Join(rt, "neferwl")); err != nil || fi.Mode().Perm() != 0o700 {
58 t.Fatalf("directory %v %v", fi, err)
59 }
60}
61
62// ScreenshotArea: the output containing the corner, with a clipped,
63// output-local region; outside every output is InvalidArgs.
64func TestScreenshotAreaMapsToRegion(t *testing.T) {
65 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
66 h, c := ready(t, harnessOpts{})
67 got := make(chan []string, 1)
68 h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
69 got <- slices.Clone(args)
70 writeShot(t, args)
71 return nil
72 }).Once()
73 area := func(x, y, w, hh int32) func(*zerobus.Encoder) {
74 return func(e *zerobus.Encoder) {
75 e.Int32(x)
76 e.Int32(y)
77 e.Int32(w)
78 e.Int32(hh)
79 e.Bool(false)
80 e.Str("f")
81 }
82 }
83 // HEADLESS-2 sits at x=64, 32x32: the area starts at (70,4), wider than the rest of the output.
84 if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(70, 4, 100, 10)); name != "" {
85 t.Fatal(name)
86 }
87 args := <-got
88 if args[1] != "-output" || args[2] != "HEADLESS-2" || args[3] != "-region" || args[4] != "6,4,26x10" {
89 t.Fatalf("args %v", args)
90 }
91 if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(500, 4, 10, 10)); name != errInvalidArgs {
92 t.Fatalf("outside: %q", name)
93 }
94 if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, "ScreenshotArea", "iiiibs", area(0, 0, 0, 10)); name != errInvalidArgs {
95 t.Fatalf("empty: %q", name)
96 }
97}
98
99// InteractiveScreenshot: nefercap shot, answered with a file:// uri. A second
100// shot while one runs is LimitsExceeded; a failure is Failed and the busy
101// flag clears.
102func TestInteractiveScreenshotBusyAndFailure(t *testing.T) {
103 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
104 h, c := ready(t, harnessOpts{})
105 release := make(chan error)
106 got := make(chan []string, 2)
107 h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
108 got <- slices.Clone(args)
109 err := <-release
110 if err == nil {
111 writeShot(t, args)
112 }
113 return err
114 }).Twice()
115
116 c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
117 first, err := c.Send()
118 if err != nil {
119 t.Fatal(err)
120 }
121 args := <-got
122 if len(args) != 3 || args[0] != "shot" || args[1] != "-file" {
123 t.Fatalf("args %v", args)
124 }
125 other := h.client()
126 if name, _ := callErr(other, nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil); name != errLimits {
127 t.Fatalf("second shot: %q", name)
128 }
129 release <- nil
130 m, err := c.ReadMessage()
131 if err != nil || m.ReplySerial != first {
132 t.Fatalf("reply %v %v", m, err)
133 }
134 r := m.Body()
135 if ok, uri := r.Bool(), r.Str(); !ok || uri != "file://"+args[2] {
136 t.Fatalf("reply %v %q", ok, uri)
137 }
138
139 // nefercap fails (or is missing): Failed, and the next shot is allowed.
140 c.NewCall(nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "")
141 if _, err := c.Send(); err != nil {
142 t.Fatal(err)
143 }
144 <-got
145 release <- os.ErrNotExist
146 m, err = c.ReadMessage()
147 if err != nil || m.ErrorName != errFailed {
148 t.Fatalf("failure reply %+v %v", m, err)
149 }
150}
151
152// What the dialog offers beyond the supported calls is NotSupported.
153func TestScreenshotUnsupportedMethods(t *testing.T) {
154 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
155 _, c := ready(t, harnessOpts{})
156 for _, member := range []string{"SelectArea", "ScreenshotWindow", "PickColor", "FlashArea"} {
157 if name, _ := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, "", nil); name != errNotSupported && name != errInvalidArgs {
158 t.Errorf("%s: %q", member, name)
159 }
160 }
161}
162
163// nefercap that exits cleanly without leaving a regular, non-empty file is
164// not a success: the file is missing, empty, or a symlink.
165func TestScreenshotWithoutUsableFileIsNotSuccess(t *testing.T) {
166 rt := t.TempDir()
167 t.Setenv("XDG_RUNTIME_DIR", rt)
168 target := filepath.Join(rt, "real.png")
169 if err := os.WriteFile(target, []byte("png"), 0o600); err != nil {
170 t.Fatal(err)
171 }
172 for name, mk := range map[string]func(path string) error{
173 "missing": func(string) error { return nil },
174 "empty": func(path string) error { return os.WriteFile(path, nil, 0o600) },
175 "symlink": func(path string) error { return os.Symlink(target, path) },
176 "dir": func(path string) error { return os.Mkdir(path, 0o700) },
177 } {
178 t.Run(name, func(t *testing.T) {
179 h, c := ready(t, harnessOpts{})
180 h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
181 if err := mk(args[slices.Index(args, "-file")+1]); err != nil {
182 t.Error(err)
183 }
184 return nil
185 }).Once()
186 for _, member := range []string{"Screenshot", "InteractiveScreenshot"} {
187 if member == "InteractiveScreenshot" {
188 h.run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, args []string) error {
189 if err := mk(args[slices.Index(args, "-file")+1]); err != nil {
190 t.Error(err)
191 }
192 return nil
193 }).Once()
194 }
195 sig := ""
196 body := func(*zerobus.Encoder) {}
197 if member == "Screenshot" {
198 sig = "bbs"
199 body = func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }
200 }
201 errName, m := callErr(c, nameScreenshot, pathScreenshot, ifaceScreenshot, member, sig, body)
202 if errName != "" {
203 t.Fatalf("%s: %s", member, errName)
204 }
205 r := m.Body()
206 if ok, file := r.Bool(), r.Str(); ok || file != "" {
207 t.Fatalf("%s: success %v %q without a file", member, ok, file)
208 }
209 }
210 })
211 }
212}
213
214// A result that belongs to another job completes nothing, and a job that
215// the attempt's end cancels is joined before its state is reset.
216func TestStaleScreenshotResultIsDropped(t *testing.T) {
217 p := newProducer(t)
218 run := newMockrunner(t)
219 p.s.runner = run
220 ctx, cancel := context.WithCancel(context.Background())
221 defer cancel()
222 p.s.ctx = ctx
223 started := make(chan struct{})
224 var seen error
225 run.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(func(c context.Context, _ []string) error {
226 close(started)
227 <-c.Done()
228 seen = c.Err()
229 return c.Err()
230 }).Once()
231 p.bus.EXPECT().NewError(mock.Anything, errFailed, "s").RunAndReturn(
232 func(*zerobus.Message, string, string) *zerobus.Encoder { return &zerobus.Encoder{} }).Once()
233
234 p.s.shotSeq++
235 p.s.shot = shotState{busy: true, id: p.s.shotSeq, path: filepath.Join(t.TempDir(), "x.png")}
236 p.s.runShot([]string{"shot"})
237 <-started
238 // A result of some earlier job is in the transport: it is not this job's.
239 p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true})
240 if !p.s.shot.busy {
241 t.Fatal("a stale result completed the job in flight")
242 }
243 // The attempt ends: the job is cancelled and joined before the reset.
244 p.s.stopShot()
245 if seen == nil {
246 t.Fatal("stopShot returned before the job saw its cancellation")
247 }
248 if p.s.shot.busy || p.s.shotRes.ready {
249 t.Fatal("job state survives the attempt")
250 }
251 // Its own late result (not possible after the join, but harmless) and a
252 // new job with a new ID: the first job's ID completes nothing.
253 p.s.shotSeq++
254 p.s.shot = shotState{busy: true, id: p.s.shotSeq}
255 p.s.onShotDone(&shotResult{ready: true, id: p.s.shotSeq - 1, made: true})
256 if !p.s.shot.busy {
257 t.Fatal("a result of the cancelled job completed the next request")
258 }
259}