v0.3.0 da0887c

dbusmenu_test.go RawHistoryFolder

1package tray
2
3import (
4	"encoding/binary"
5	"fmt"
6	"slices"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/bnema/zerobus"
12
13	"github.com/bnema/neferbar/internal/module"
14)
15
16// tprop is one property of a test menu node.
17type tprop struct {
18	k string
19	v any // string, bool or int32
20}
21
22// tnode is a node of the layout a fake application serves.
23type tnode struct {
24	id    int32
25	props []tprop
26	kids  []tnode
27	// junk is written as an extra child with a signature that is not a layout node.
28	junk bool
29}
30
31func (n *tnode) encode(e *zerobus.Encoder) {
32	e.Struct()
33	e.Int32(n.id)
34	a := e.BeginArray('{')
35	for _, p := range n.props {
36		e.Struct()
37		e.Str(p.k)
38		switch v := p.v.(type) {
39		case string:
40			e.Variant("s")
41			e.Str(v)
42		case bool:
43			e.Variant("b")
44			e.Bool(v)
45		case int32:
46			e.Variant("i")
47			e.Int32(v)
48		}
49	}
50	e.EndArray(a)
51	kids := e.BeginArray('v')
52	if n.junk {
53		e.Variant("s")
54		e.Str("not a node")
55	}
56	for i := range n.kids {
57		e.Variant(layoutSig)
58		n.kids[i].encode(e)
59	}
60	e.EndArray(kids)
61}
62
63// menuCall answers a dbusmenu call and records it on mcalls.
64func (f *fakeItem) menuCall(m *zerobus.Message) bool {
65	r := m.Body()
66	switch m.Member {
67	case "AboutToShow":
68		f.mcalls <- fmt.Sprintf("AboutToShow(%d)", r.Int32())
69		f.c.NewReply(m, "b").Bool(false)
70	case "Event":
71		id, name := r.Int32(), r.Str()
72		r.Skip(r.Variant())
73		f.mcalls <- fmt.Sprintf("Event(%d,%s)", id, name)
74		f.c.NewReply(m, "")
75	case "GetLayout":
76		f.mcalls <- "GetLayout"
77		if f.menu == nil {
78			f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownObject", "s").Str("no menu")
79			break
80		}
81		if f.badSig {
82			f.c.NewReply(m, "s").Str("not a layout")
83			break
84		}
85		e := f.c.NewReply(m, "u"+layoutSig)
86		e.Uint32(1)
87		f.menu.encode(e)
88	default:
89		return true
90	}
91	time.Sleep(f.mslow[m.Member]) // a slow application: the reply is built, not sent yet
92	_, err := f.c.Send()
93	return err == nil
94}
95
96func expectMenuCall(t *testing.T, f *fakeItem, want string) {
97	t.Helper()
98	select {
99	case got := <-f.mcalls:
100		if got != want {
101			t.Fatalf("menu got %s, want %s", got, want)
102		}
103	case <-time.After(5 * time.Second):
104		t.Fatalf("menu never got %s", want)
105	}
106}
107
108func expectNoMenuCall(t *testing.T, f *fakeItem) {
109	t.Helper()
110	select {
111	case got := <-f.mcalls:
112		t.Fatalf("unexpected menu call %s", got)
113	case <-time.After(150 * time.Millisecond):
114	}
115}
116
117func sampleLayout() *tnode {
118	return &tnode{id: 0, kids: []tnode{
119		{id: 1, props: []tprop{{"label", "_Open"}}},
120		{id: 2, props: []tprop{{"type", "separator"}}},
121		{id: 3, props: []tprop{{"label", "Mu__te"}, {"toggle-type", "checkmark"}, {"toggle-state", int32(1)}}},
122		{id: 4, props: []tprop{{"label", "Settings"}, {"children-display", "submenu"}}, kids: []tnode{
123			{id: 5, props: []tprop{{"label", "Advanced"}}},
124		}},
125		{id: 6, props: []tprop{{"label", "Quit"}, {"enabled", false}}},
126		{id: 7, props: []tprop{{"label", "Hidden"}, {"visible", false}}, kids: []tnode{{id: 70, props: []tprop{{"label", "Under hidden"}}}}},
127		{id: 8, props: []tprop{{"label", "Radio"}, {"toggle-type", "radio"}, {"toggle-state", int32(0)}}},
128	}, junk: true}
129}
130
131// menuReady gives steam a dbusmenu and waits until the tray knows it: right
132// clicks fall back to ContextMenu until then, and the first menu line proves it.
133func menuReady(t *testing.T, steam *fakeItem, in barInput, out *lines, layout *tnode) module.Control {
134	t.Helper()
135	steam.run(t, func() {
136		steam.menu = layout
137		steam.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str("Active")
138		_, _ = steam.c.Send()
139	})
140	deadline := time.Now().Add(5 * time.Second)
141	for time.Now().Before(deadline) {
142		in.send(t, "click right 0 9")
143		wait := time.After(600 * time.Millisecond)
144	poll:
145		for {
146			select {
147			case s := <-out.ch:
148				if j, ok := strings.CutPrefix(s, controlPrefix); ok && strings.Contains(j, `"type":"menu"`) {
149					c, err := module.ParseControl([]byte(strings.TrimSuffix(j, "\x07")))
150					if err != nil {
151						t.Fatalf("the bar would refuse the menu line %q: %v", j, err)
152					}
153					// Drain what the successful open recorded.
154					for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
155						expectMenuCall(t, steam, want)
156					}
157					return c
158				}
159			case <-steam.calls: // ContextMenu: the tray has not seen the menu yet
160				break poll
161			case <-wait:
162				break poll
163			}
164		}
165		for len(steam.mcalls) > 0 {
166			<-steam.mcalls
167		}
168	}
169	t.Fatal("the tray never produced a menu")
170	return module.Control{}
171}
172
173func TestRightClickOpensTheDbusmenu(t *testing.T) {
174	steam, _, in, out := setup(t)
175	c := menuReady(t, steam, in, out, sampleLayout())
176
177	if c.Type != module.ControlMenu || c.Click != 9 || c.Col != 0 || c.Width != 1 {
178		t.Fatalf("control = %+v", c)
179	}
180	type flat struct {
181		id           int32
182		label, kind  string
183		enabled, chk bool
184		kids         int
185	}
186	var got []flat
187	for _, it := range c.Items {
188		got = append(got, flat{it.ID, it.Label, it.Kind, it.Enabled, it.Checked, len(it.Items)})
189	}
190	want := []flat{
191		{1, "Open", "normal", true, false, 0},
192		{2, "", "separator", true, false, 0},
193		{3, "Mute", "check", true, true, 0}, // "__" is a literal underscore: "Mu_te"
194		{4, "Settings", "normal", true, false, 1},
195		{6, "Quit", "normal", false, false, 0},
196		{8, "Radio", "radio", true, false, 0},
197	}
198	want[2].label = "Mu_te"
199	if fmt.Sprint(got) != fmt.Sprint(want) {
200		t.Fatalf("items = %+v\nwant   %+v", got, want)
201	}
202	if sub := c.Items[3].Items[0]; sub.ID != 5 || sub.Label != "Advanced" {
203		t.Fatalf("submenu = %+v", c.Items[3].Items)
204	}
205	expectNoCall(t, steam) // no ContextMenu: the bar draws the menu
206
207	// A choice reaches the application, once.
208	in.send(t, "menu-activate 9 5")
209	expectMenuCall(t, steam, "Event(5,clicked)")
210	in.send(t, "menu-activate 9 5", "menu-closed 9")
211	expectNoMenuCall(t, steam)
212}
213
214func TestMenuClosedAndStaleTokens(t *testing.T) {
215	steam, _, in, out := setup(t)
216	menuReady(t, steam, in, out, sampleLayout())
217
218	in.send(t, "menu-activate 8 1") // another token
219	in.send(t, "menu-closed 8")
220	in.send(t, "menu-activate 9 99") // an id the menu does not hold
221	in.send(t, "menu-activate 9 7")  // an invisible item
222	in.send(t, "menu-activate 9 70") // a child of an invisible item
223	expectNoMenuCall(t, steam)
224
225	in.send(t, "menu-closed 9")
226	expectMenuCall(t, steam, "Event(0,closed)")
227	in.send(t, "menu-closed 9", "menu-activate 9 1") // forgotten after closing
228	expectNoMenuCall(t, steam)
229}
230
231func TestNewerMenuReplacesTheOlder(t *testing.T) {
232	steam, _, in, out := setup(t)
233	menuReady(t, steam, in, out, sampleLayout())
234	in.send(t, "click right 0 10")
235	out.waitFor(t, "the second menu", func(s string) bool { return strings.Contains(s, `"click":10`) })
236	for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout"} {
237		expectMenuCall(t, steam, want)
238	}
239	in.send(t, "menu-activate 9 1") // the first menu is gone
240	expectNoMenuCall(t, steam)
241	in.send(t, "menu-activate 10 1")
242	expectMenuCall(t, steam, "Event(1,clicked)")
243}
244
245func TestLeftClickOnAMenuOnlyItemOpensTheMenu(t *testing.T) {
246	steam, _, in, out := setup(t)
247	menuReady(t, steam, in, out, sampleLayout())
248	// ItemIsMenu is not served by the fake, so the left click calls Activate,
249	// which it refuses: the fallback opens the menu.
250	steam.run(t, func() { steam.unknown = "Activate" })
251	in.send(t, "click left 0 12")
252	expectCall(t, steam, "Activate(0,0)")
253	out.waitFor(t, "the menu after the fallback", func(s string) bool { return strings.Contains(s, `"click":12`) })
254}
255
256func TestMenuLayoutLimitsAndDecoding(t *testing.T) {
257	// Build a deep and wide layout and decode it through the real reply path.
258	deep := tnode{id: 100, props: []tprop{{"label", "d"}}}
259	for i := int32(1); i < 12; i++ {
260		deep = tnode{id: 100 + i, props: []tprop{{"label", "d"}}, kids: []tnode{deep}}
261	}
262	root := tnode{id: 0, kids: []tnode{deep}}
263	for i := int32(0); i < 600; i++ {
264		root.kids = append(root.kids, tnode{id: 1000 + i, props: []tprop{{"label", strings.Repeat("x", 300)}}})
265	}
266	items, ids := decodeTestLayout(t, &root)
267	if n := countItems(items); n > maxMenuItems || len(ids) != n {
268		t.Fatalf("items %d, ids %d, want at most %d and equal", n, len(ids), maxMenuItems)
269	}
270	depth := 0
271	for l := items; len(l) > 0; l = l[0].Items {
272		depth++
273	}
274	if depth != maxMenuDepth {
275		t.Fatalf("depth %d, want %d", depth, maxMenuDepth)
276	}
277	if got := len(items[len(items)-1].Label); got != 256 {
278		t.Fatalf("label of %d bytes, want 256", got)
279	}
280	line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
281	if err != nil || len(line) > maxMenuLine {
282		t.Fatalf("encodeMenu: %d bytes, %v", len(line), err)
283	}
284	if _, err = module.ParseControl(line); err != nil {
285		t.Fatalf("the bar refuses the trimmed menu: %v", err)
286	}
287}
288
289func TestMenuLabelMnemonics(t *testing.T) {
290	for in, want := range map[string]string{
291		"_Open": "Open", "Mu__te": "Mu_te", "plain": "plain", "": "", "_": "", "a_": "a", "__": "_", "_a_b": "ab", "é_è": "éè",
292	} {
293		if got := cleanLabel(in); got != want {
294			t.Errorf("cleanLabel(%q) = %q, want %q", in, got, want)
295		}
296	}
297}
298
299// decodeTestLayout encodes root as a GetLayout reply and decodes it again.
300func decodeTestLayout(t *testing.T, root *tnode) ([]menuItem, []int32) {
301	t.Helper()
302	addr := privateBus(t)
303	srv, cli := dial(t, addr), dial(t, addr)
304	done := make(chan struct{})
305	go func() {
306		defer close(done)
307		var m *zerobus.Message
308		for { // the bus first sends signals such as NameAcquired
309			var err error
310			if m, err = srv.ReadMessage(); err != nil {
311				return
312			}
313			if m.Type == zerobus.TypeMethodCall {
314				break
315			}
316		}
317		e := srv.NewReply(m, "u"+layoutSig)
318		e.Uint32(1)
319		root.encode(e)
320		_, _ = srv.Send()
321	}()
322	cli.NewCall(srv.UniqueName(), "/MenuBar", menuIface, "GetLayout", "")
323	m, err := cli.Call()
324	if err != nil {
325		t.Fatal(err)
326	}
327	items, ids, err := decodeLayout(m.Body())
328	if err != nil {
329		t.Fatal(err)
330	}
331	_ = srv.Close()
332	<-done
333	return items, ids
334}
335
336// A slow application cannot hold the tray for more than the shared budget, and
337// it is told the menu is closed when it was told it opened.
338func TestSlowMenuUsesOneDeadlineAndIsClosed(t *testing.T) {
339	old := menuBudget
340	menuBudget = 1200 * time.Millisecond
341	t.Cleanup(func() { menuBudget = old })
342
343	steam, _, in, out := setup(t)
344	menuReady(t, steam, in, out, sampleLayout()) // the tray knows the menu now
345	in.send(t, "menu-closed 9")
346	expectMenuCall(t, steam, "Event(0,closed)")
347
348	// Every call takes 500 ms: each is under the 2 s call limit, and the
349	// sequence runs out of its 1.2 s while GetLayout is waiting.
350	steam.run(t, func() {
351		steam.mslow = map[string]time.Duration{"AboutToShow": 500 * time.Millisecond, "Event": 500 * time.Millisecond, "GetLayout": 500 * time.Millisecond}
352	})
353	start := time.Now()
354	in.send(t, "click right 0 20")
355	expectMenuCall(t, steam, "AboutToShow(0)")
356	expectMenuCall(t, steam, "Event(0,opened)")
357	expectMenuCall(t, steam, "GetLayout")
358	// GetLayout ran out of budget: the application hears "closed".
359	expectMenuCall(t, steam, "Event(0,closed)")
360	if d := time.Since(start); d > 3*time.Second {
361		t.Fatalf("opening the menu took %v", d)
362	}
363	noControlLineFor(t, out, "menu", 300*time.Millisecond)
364	// The menu is forgotten: a late choice does nothing.
365	in.send(t, "menu-activate 20 1")
366	expectNoMenuCall(t, steam)
367}
368
369// An application with an empty menu is also told that it closed.
370func TestEmptyMenuIsClosedAgain(t *testing.T) {
371	steam, _, in, out := setup(t)
372	menuReady(t, steam, in, out, sampleLayout())
373	in.send(t, "menu-closed 9")
374	expectMenuCall(t, steam, "Event(0,closed)")
375	steam.run(t, func() { steam.menu = &tnode{id: 0} })
376	// The tray still thinks it has a menu path: the layout is just empty.
377	in.send(t, "click right 0 21")
378	for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
379		expectMenuCall(t, steam, want)
380	}
381	in.send(t, "menu-activate 21 1")
382	expectNoMenuCall(t, steam)
383}
384
385// noControlLineFor fails when a control line of the given type arrives within d.
386func noControlLineFor(t *testing.T, out *lines, typ string, d time.Duration) {
387	t.Helper()
388	timeout := time.After(d)
389	for {
390		select {
391		case s, ok := <-out.ch:
392			if ok && strings.HasPrefix(s, controlPrefix) && strings.Contains(s, `"type":"`+typ+`"`) {
393				t.Fatalf("unexpected %s line %q", typ, s)
394			}
395		case <-timeout:
396			return
397		}
398	}
399}
400
401// A reply that is not a layout makes no menu, and the application hears that
402// the menu it was told to open is closed.
403func TestUnexpectedLayoutSignature(t *testing.T) {
404	steam, _, in, out := setup(t)
405	menuReady(t, steam, in, out, sampleLayout())
406	in.send(t, "menu-closed 9")
407	expectMenuCall(t, steam, "Event(0,closed)")
408	steam.run(t, func() { steam.badSig = true })
409	in.send(t, "click right 0 30")
410	for _, want := range []string{"AboutToShow(0)", "Event(0,opened)", "GetLayout", "Event(0,closed)"} {
411		expectMenuCall(t, steam, want)
412	}
413	noControlLineFor(t, out, "menu", 300*time.Millisecond)
414	in.send(t, "menu-activate 30 1")
415	expectNoMenuCall(t, steam)
416}
417
418// decodeRaw sends body as the reply to one call on c and decodes it.
419func decodeRaw(t testing.TB, c *zerobus.Conn, next chan<- reply, sig string, body []byte) ([]menuItem, []int32, error) {
420	t.Helper()
421	next <- reply{sig, body}
422	c.NewCall("x.y", "/MenuBar", menuIface, "GetLayout", "")
423	m, err := c.Call()
424	if err != nil {
425		t.Fatal(err)
426	}
427	return decodeLayout(m.Body())
428}
429
430// Bodies that are cut short or inconsistent must make an error, never a panic
431// or an unbounded read.
432func TestDecodeLayoutTruncatedAndMalformed(t *testing.T) {
433	addr, next := rawBus(t)
434	c := rawDial(t, addr)
435	good := layoutBody(sampleLayout())
436	if items, _, err := decodeRaw(t, c, next, "u"+layoutSig, good); err != nil || len(items) != 6 {
437		t.Fatalf("the hand-built body does not decode like the encoder's: %d items, %v", len(items), err)
438	}
439	// Every strict prefix of a valid body.
440	for n := 0; n < len(good); n++ {
441		if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, good[:n]); err == nil {
442			t.Fatalf("a body cut at %d of %d bytes decoded without error", n, len(good))
443		}
444	}
445	// A child array that claims more bytes than the message has.
446	bad := slices.Clone(good)
447	// The children array of the root sits right after the root's properties
448	// (empty): find it by decoding position: revision(4) + pad(4) + id(4) + props len(4) + pad + kids len.
449	binary.LittleEndian.PutUint32(bad[16:], 1<<20)
450	if _, _, err := decodeRaw(t, c, next, "u"+layoutSig, bad); err == nil {
451		t.Fatal("an oversized child array decoded without error")
452	}
453	// An item with a property of the wrong type: ignored, not an error.
454	odd := &tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", int32(5)}, {"enabled", "yes"}, {"visible", int32(0)}}}}}
455	items, _, err := decodeRaw(t, c, next, "u"+layoutSig, layoutBody(odd))
456	if err != nil || len(items) != 1 || items[0].Label != "" || !items[0].Enabled {
457		t.Fatalf("odd properties: %+v, %v", items, err)
458	}
459	// Garbage of every kind does not panic.
460	for _, junk := range [][]byte{nil, {0}, make([]byte, 7), make([]byte, 64), slices.Repeat([]byte{0xff}, 100)} {
461		decodeRaw(t, c, next, "u"+layoutSig, junk)
462	}
463}
464
465func FuzzDecodeLayout(f *testing.F) {
466	addr, next := rawBus(f)
467	c := rawDial(f, addr)
468	f.Add(layoutBody(sampleLayout()))
469	f.Add(layoutBody(&tnode{id: 0}))
470	f.Add(layoutBody(&tnode{id: 0, kids: []tnode{{id: 1, props: []tprop{{"label", "x"}, {"type", "separator"}}}}}))
471	f.Add(layoutBody(sampleLayout())[:30])
472	f.Add([]byte{})
473	f.Fuzz(func(t *testing.T, body []byte) {
474		if len(body) > 1<<16 {
475			return
476		}
477		items, ids, err := decodeRaw(t, c, next, "u"+layoutSig, body)
478		if err != nil {
479			return
480		}
481		if n := countItems(items); n > maxMenuItems || len(ids) != n {
482			t.Fatalf("%d items, %d ids", n, len(ids))
483		}
484		var walk func(l []menuItem, depth int)
485		walk = func(l []menuItem, depth int) {
486			if len(l) > 0 && depth > maxMenuDepth {
487				t.Fatalf("depth %d", depth)
488			}
489			for _, it := range l {
490				if len(it.Label) > maxMenuLabel || it.ID < 0 {
491					t.Fatalf("item %+v", it)
492				}
493				walk(it.Items, depth+1)
494			}
495		}
496		walk(items, 1)
497		// Whatever decodes must be acceptable to the bar (an empty menu is
498		// not sent at all).
499		if len(items) == 0 {
500			return
501		}
502		line, _, err := encodeMenu(menuLine{Type: "menu", Width: 1, Click: 1, Items: items})
503		if err == nil {
504			if _, err = module.ParseControl(line); err != nil {
505				t.Fatalf("the bar refuses a decoded menu: %v", err)
506			}
507		}
508	})
509}

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 5

Tags 4

↑↓ moveenter openesc close