feat/data-dnd e05cb15

screenshot_test.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close