v0.3.0 cf370a0

screensaver_test.go RawHistoryFolder

1package screensaver
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"os"
8	"os/exec"
9	"path/filepath"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/bnema/neferwl/internal/logging"
15	"github.com/godbus/dbus/v5"
16)
17
18// privateBus starts a dbus-daemon of its own, so tests never touch the
19// desktop's session bus.
20func privateBus(t *testing.T) string {
21	t.Helper()
22	address, _ := startBus(t)
23	return address
24}
25
26// startBus also returns the daemon, for tests that kill it.
27func startBus(t *testing.T) (string, *exec.Cmd) {
28	t.Helper()
29	return startBusAt(t, filepath.Join(t.TempDir(), "bus"))
30}
31
32// startBusAt listens on socket, so a test can restart a bus at the same
33// address. CI must run these tests: there, a missing daemon fails.
34func startBusAt(t *testing.T, socket string) (string, *exec.Cmd) {
35	t.Helper()
36	bin, err := exec.LookPath("dbus-daemon")
37	if err != nil {
38		if os.Getenv("CI") != "" {
39			t.Fatal("dbus-daemon not installed")
40		}
41		t.Skip("dbus-daemon not installed")
42	}
43	_ = os.Remove(socket)
44	cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket)
45	out, err := cmd.StdoutPipe()
46	if err != nil {
47		t.Fatal(err)
48	}
49	if err := cmd.Start(); err != nil {
50		t.Fatal(err)
51	}
52	t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
53	line, err := bufio.NewReader(out).ReadString('\n')
54	if err != nil {
55		t.Fatal(err)
56	}
57	return strings.TrimSpace(line), cmd
58}
59
60func connect(t *testing.T, address string) *dbus.Conn {
61	t.Helper()
62	c, err := dbus.Connect(address)
63	if err != nil {
64		t.Fatal(err)
65	}
66	t.Cleanup(func() { _ = c.Close() })
67	return c
68}
69
70// served is a running service and what it reports.
71type served struct {
72	s        *Service
73	held     chan bool
74	activity chan struct{}
75}
76
77// serve runs the service on address until the test ends.
78func serve(t *testing.T, address string) served {
79	t.Helper()
80	ctx, cancel := context.WithCancel(context.Background())
81	s, err := New(ctx, address, logging.For(ctx, "screensaver"))
82	if err != nil {
83		cancel()
84		t.Fatal(err)
85	}
86	v := served{s: s, held: make(chan bool, 8), activity: make(chan struct{}, 1)}
87	done := make(chan error, 1)
88	go func() { done <- s.Run(ctx, Reports{Held: v.held, Activity: v.activity}) }()
89	t.Cleanup(func() {
90		cancel()
91		if err := <-done; err != nil {
92			t.Error(err)
93		}
94	})
95	// Wait for the name: Run requests it after exporting.
96	waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" })
97	return v
98}
99
100// waitOwner polls the owner of busName ("" when none) until ok accepts it.
101func waitOwner(t *testing.T, probe *dbus.Conn, ok func(string) bool) {
102	t.Helper()
103	deadline := time.Now().Add(2 * time.Second)
104	for {
105		var owner string
106		_ = probe.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner)
107		if ok(owner) {
108			return
109		}
110		if time.Now().After(deadline) {
111			t.Fatalf("owner %q not accepted", owner)
112		}
113		time.Sleep(5 * time.Millisecond)
114	}
115}
116
117func next(t *testing.T, held <-chan bool, want bool) {
118	t.Helper()
119	select {
120	case got := <-held:
121		if got != want {
122			t.Fatalf("held %v, want %v", got, want)
123		}
124	case <-time.After(2 * time.Second):
125		t.Fatalf("no held %v", want)
126	}
127}
128
129func none(t *testing.T, held <-chan bool) {
130	t.Helper()
131	select {
132	case got := <-held:
133		t.Fatalf("unexpected held %v", got)
134	case <-time.After(100 * time.Millisecond):
135	}
136}
137
138func inhibit(t *testing.T, c *dbus.Conn, path dbus.ObjectPath) uint32 {
139	t.Helper()
140	var cookie uint32
141	if err := c.Object(busName, path).Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err != nil {
142		t.Fatal(err)
143	}
144	return cookie
145}
146
147func TestInhibitAndUnInhibit(t *testing.T) {
148	address := privateBus(t)
149	held := serve(t, address).held
150	a, b := connect(t, address), connect(t, address)
151	ca := inhibit(t, a, "/org/freedesktop/ScreenSaver")
152	next(t, held, true)
153	cb := inhibit(t, b, "/ScreenSaver")
154	none(t, held)
155	// b cannot release a's cookie.
156	if err := b.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil {
157		t.Fatal(err)
158	}
159	if err := a.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil {
160		t.Fatal(err)
161	}
162	none(t, held)
163	if err := b.Object(busName, "/ScreenSaver").Call(iface+".UnInhibit", 0, cb).Err; err != nil {
164		t.Fatal(err)
165	}
166	next(t, held, false)
167	obj := a.Object(busName, "/org/freedesktop/ScreenSaver")
168	var active bool
169	if err := obj.Call(iface+".GetActive", 0).Store(&active); err != nil || active {
170		t.Fatalf("GetActive %v %v", active, err)
171	}
172	var since uint32
173	if err := obj.Call(iface+".GetActiveTime", 0).Store(&since); err != nil || since != 0 {
174		t.Fatalf("GetActiveTime %v %v", since, err)
175	}
176	if err := obj.Call(iface+".SetActive", 0, true).Store(&active); err != nil || active {
177		t.Fatalf("SetActive %v %v", active, err)
178	}
179	var xml string
180	if err := obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil || !strings.Contains(xml, `<method name="UnInhibit">`) || !strings.Contains(xml, `<method name="SimulateUserActivity">`) {
181		t.Fatalf("introspection %q %v", xml, err)
182	}
183}
184
185// SimulateUserActivity reports activity without blocking: calls the
186// reader has not taken yet collapse into one.
187func TestSimulateUserActivity(t *testing.T) {
188	address := privateBus(t)
189	v := serve(t, address)
190	obj := connect(t, address).Object(busName, "/ScreenSaver")
191	for range 3 {
192		if err := obj.Call(iface+".SimulateUserActivity", 0).Err; err != nil {
193			t.Fatal(err)
194		}
195	}
196	<-v.activity
197	select {
198	case <-v.activity:
199		t.Fatal("activity not coalesced")
200	default:
201	}
202}
203
204// A client that disconnects without UnInhibit, as a crash does, releases
205// its inhibitions.
206func TestDisconnectReleases(t *testing.T) {
207	address := privateBus(t)
208	held := serve(t, address).held
209	c, err := dbus.Connect(address)
210	if err != nil {
211		t.Fatal(err)
212	}
213	inhibit(t, c, "/org/freedesktop/ScreenSaver")
214	inhibit(t, c, "/org/freedesktop/ScreenSaver")
215	next(t, held, true)
216	_ = c.Close()
217	next(t, held, false)
218}
219
220// A NameOwnerChanged sent straight to the service by a client is not the
221// bus's: it cannot end another client's inhibition.
222func TestSpoofedDepartureIgnored(t *testing.T) {
223	address := privateBus(t)
224	held := serve(t, address).held
225	victim, attacker := connect(t, address), connect(t, address)
226	inhibit(t, victim, "/org/freedesktop/ScreenSaver")
227	next(t, held, true)
228	var service string
229	if err := attacker.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&service); err != nil {
230		t.Fatal(err)
231	}
232	gone := victim.Names()[0]
233	msg := &dbus.Message{
234		Type: dbus.TypeSignal,
235		Headers: map[dbus.HeaderField]dbus.Variant{
236			dbus.FieldPath:        dbus.MakeVariant(dbus.ObjectPath("/org/freedesktop/DBus")),
237			dbus.FieldInterface:   dbus.MakeVariant("org.freedesktop.DBus"),
238			dbus.FieldMember:      dbus.MakeVariant("NameOwnerChanged"),
239			dbus.FieldDestination: dbus.MakeVariant(service),
240			dbus.FieldSignature:   dbus.MakeVariant(dbus.SignatureOf("", "", "")),
241		},
242		Body: []any{gone, gone, ""},
243	}
244	if call := attacker.Send(msg, nil); call != nil && call.Err != nil {
245		t.Fatal(call.Err)
246	}
247	// A call after the signal: the service has read the signal by its reply.
248	inhibit(t, attacker, "/ScreenSaver")
249	none(t, held)
250	_ = attacker.Close()
251	none(t, held)
252}
253
254// A client that leaves right after its Inhibit, before the service handles
255// the call, holds nothing: its departure may come first.
256func TestEarlyDepartureHoldsNothing(t *testing.T) {
257	address := privateBus(t)
258	held := serve(t, address).held
259	for range 20 {
260		c, err := dbus.Connect(address)
261		if err != nil {
262			t.Fatal(err)
263		}
264		c.Object(busName, "/ScreenSaver").Go(iface+".Inhibit", dbus.FlagNoReplyExpected, nil, "test", "video")
265		_ = c.Close()
266	}
267	// Each one may hold briefly; once all are handled, none does.
268	state := false
269	deadline := time.After(3 * time.Second)
270	for quiet := false; !quiet; {
271		select {
272		case state = <-held:
273		case <-time.After(300 * time.Millisecond):
274			quiet = true
275		case <-deadline:
276			t.Fatal("never settled")
277		}
278	}
279	if state {
280		t.Fatal("a departed client still holds an inhibition")
281	}
282}
283
284// One connection holds at most maxPerSender cookies; the next is refused.
285func TestPerSenderLimit(t *testing.T) {
286	address := privateBus(t)
287	held := serve(t, address).held
288	c := connect(t, address)
289	obj := c.Object(busName, "/ScreenSaver")
290	for range maxPerSender {
291		inhibit(t, c, "/ScreenSaver")
292	}
293	next(t, held, true)
294	var cookie uint32
295	if err := obj.Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err == nil {
296		t.Fatalf("cookie %d past the limit", cookie)
297	}
298	// Another connection is not limited by the first.
299	inhibit(t, connect(t, address), "/ScreenSaver")
300}
301
302// A call that reaches the handler after Run returned fails at once
303// instead of waiting for a loop that is gone.
304func TestCallAfterStop(t *testing.T) {
305	address := privateBus(t)
306	ctx, cancel := context.WithCancel(context.Background())
307	s, err := New(ctx, address, logging.For(ctx, "screensaver"))
308	if err != nil {
309		cancel()
310		t.Fatal(err)
311	}
312	done := make(chan error, 1)
313	go func() { done <- s.Run(ctx, Reports{Held: make(chan bool, 1), Activity: make(chan struct{}, 1)}) }()
314	cancel()
315	if err := <-done; err != nil {
316		t.Fatal(err)
317	}
318	got := make(chan *dbus.Error, 1)
319	go func() { _, err := (handler{s: s}).Inhibit(":1.9", "test", "video"); got <- err }()
320	select {
321	case err := <-got:
322		if err == nil {
323			t.Fatal("Inhibit succeeded on a stopped service")
324		}
325	case <-time.After(2 * time.Second):
326		t.Fatal("Inhibit waits on a stopped service")
327	}
328}
329
330// The bus going away while inhibited reports false so idle resumes.
331func TestBusLossReleases(t *testing.T) {
332	address, cmd := startBus(t)
333	ctx, cancel := context.WithCancel(context.Background())
334	defer cancel()
335	s, err := New(ctx, address, logging.For(ctx, "screensaver"))
336	if err != nil {
337		t.Fatal(err)
338	}
339	held := make(chan bool, 8)
340	done := make(chan error, 1)
341	go func() { done <- s.Run(ctx, Reports{Held: held, Activity: make(chan struct{}, 1)}) }()
342	c := connect(t, address)
343	waitOwner(t, c, func(owner string) bool { return owner != "" })
344	inhibit(t, c, "/ScreenSaver")
345	next(t, held, true)
346	_ = cmd.Process.Kill()
347	next(t, held, false)
348	select {
349	case err := <-done:
350		if err == nil {
351			t.Fatal("bus loss is not an error")
352		}
353	case <-time.After(2 * time.Second):
354		t.Fatal("Run did not return")
355	}
356}
357
358// Another owner keeps the name: Run stands aside at once, so it holds no
359// bus connection.
360func TestNameTaken(t *testing.T) {
361	address := privateBus(t)
362	other := connect(t, address)
363	if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
364		t.Fatal(r, err)
365	}
366	ctx, cancel := context.WithCancel(context.Background())
367	s, err := New(ctx, address, logging.For(ctx, "screensaver"))
368	if err != nil {
369		cancel()
370		t.Fatal(err)
371	}
372	defer cancel()
373	done := make(chan error, 1)
374	go func() { done <- s.Run(ctx, Reports{Held: make(chan bool), Activity: make(chan struct{}, 1)}) }()
375	select {
376	case err := <-done:
377		if !errors.Is(err, ErrNameTaken) {
378			t.Fatalf("err %v, want ErrNameTaken", err)
379		}
380	case <-time.After(2 * time.Second):
381		t.Fatal("Run waits instead of standing aside")
382	}
383	var owner string
384	if err := other.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner); err != nil || owner != other.Names()[0] {
385		t.Fatalf("owner %q, want %q: %v", owner, other.Names()[0], err)
386	}
387}
388
389// Serve takes the name once its owner lets it go, and serves again after
390// the bus restarts.
391func TestServeRetries(t *testing.T) {
392	socket := filepath.Join(t.TempDir(), "bus")
393	address, cmd := startBusAt(t, socket)
394	other := connect(t, address)
395	if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
396		t.Fatal(r, err)
397	}
398	ctx, cancel := context.WithCancel(context.Background())
399	held := make(chan bool, 8)
400	stopped := make(chan struct{})
401	go func() {
402		defer close(stopped)
403		Serve(ctx, address, Reports{Held: held, Activity: make(chan struct{}, 1)}, 10*time.Millisecond, logging.For(ctx, "screensaver"))
404	}()
405	t.Cleanup(func() { cancel(); <-stopped })
406	if _, err := other.ReleaseName(busName); err != nil {
407		t.Fatal(err)
408	}
409	mine := other.Names()[0]
410	waitOwner(t, other, func(owner string) bool { return owner != "" && owner != mine })
411	inhibit(t, other, "/ScreenSaver")
412	next(t, held, true)
413
414	// The bus restarts: the inhibition is gone with it, the name comes back.
415	_ = cmd.Process.Kill()
416	_ = cmd.Wait()
417	next(t, held, false)
418	address, _ = startBusAt(t, socket)
419	waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" })
420}

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