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}