1package tray
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "io"
8 "log/slog"
9 "os/exec"
10 "slices"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/bnema/zerobus"
16)
17
18// privateBus starts a dbus-daemon for the test and returns its address.
19func privateBus(t *testing.T) string {
20 t.Helper()
21 path, err := exec.LookPath("dbus-daemon")
22 if err != nil {
23 t.Skip("dbus-daemon not installed")
24 }
25 cmd := exec.Command(path, "--session", "--nofork", "--print-address")
26 out, err := cmd.StdoutPipe()
27 if err != nil {
28 t.Fatal(err)
29 }
30 if err := cmd.Start(); err != nil {
31 t.Fatal(err)
32 }
33 t.Cleanup(func() {
34 _ = cmd.Process.Kill()
35 _ = cmd.Wait()
36 })
37 addr, err := bufio.NewReader(out).ReadString('\n')
38 if err != nil {
39 t.Fatal(err)
40 }
41 return strings.TrimSpace(addr)
42}
43
44func dial(t *testing.T, addr string) *zerobus.Conn {
45 t.Helper()
46 c, err := zerobus.Dial(addr)
47 if err != nil {
48 t.Fatal(err)
49 }
50 t.Cleanup(func() { _ = c.Close() })
51 return c
52}
53
54// fakeItem is an application with one StatusNotifierItem. Its connection is
55// used only by its own goroutine, as a Conn requires: the test drives it
56// through a peer that calls its Do method.
57type fakeItem struct {
58 c *zerobus.Conn
59 name string
60 driver *zerobus.Conn
61 cmds chan func()
62 cur [3]string // id, icon name, status
63 pixel [4]byte // the one ARGB pixel of its icon
64 closed chan struct{}
65
66 // calls records the StatusNotifierItem actions it received, as
67 // "Method(args)". The fields below are set through run.
68 calls chan string
69 unknown string // the method it answers with UnknownMethod
70 slow string // the method it answers only after slowFor
71 slowFor time.Duration // how long slow takes
72 tip *[2]string // its ToolTip title and description, set through run
73 title string // its Title property, set through run
74
75 // A dbusmenu at /MenuBar, set through run.
76 menu *tnode
77 mcalls chan string // the dbusmenu calls it received
78 badSig bool // GetLayout answers with a string instead of a layout, set through run
79 mslow map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
80}
81
82func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
83 c := dial(t, addr)
84 f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
85 cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
86 calls: make(chan string, 32), mcalls: make(chan string, 32)}
87 go f.serve()
88 return f
89}
90
91// run hands fn to the item's goroutine, which runs it right after answering:
92// run may return before fn ran. Tests wait for its effect on the output.
93func (f *fakeItem) run(t *testing.T, fn func()) {
94 t.Helper()
95 f.cmds <- fn
96 f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "")
97 if _, err := f.driver.Call(); err != nil {
98 t.Fatal(err)
99 }
100}
101
102func (f *fakeItem) serve() {
103 defer close(f.closed)
104 for {
105 m, err := f.c.ReadMessage()
106 if err != nil {
107 return
108 }
109 if m.Type == zerobus.TypeMethodCall && m.Member == "Do" {
110 f.c.NewReply(m, "")
111 if _, err := f.c.Send(); err != nil {
112 return
113 }
114 (<-f.cmds)()
115 continue
116 }
117 if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface {
118 if !f.menuCall(m) {
119 return
120 }
121 continue
122 }
123 if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
124 if !f.action(m) {
125 return
126 }
127 continue
128 }
129 if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
130 continue
131 }
132 e := f.c.NewReply(m, "a{sv}")
133 a := e.BeginArray('{')
134 for i, k := range [...]string{"Id", "IconName", "Status"} {
135 e.Struct()
136 e.Str(k)
137 e.Variant("s")
138 e.Str(f.cur[i])
139 }
140 if f.tip != nil {
141 e.Struct()
142 e.Str("ToolTip")
143 e.Variant("(sa(iiay)ss)")
144 e.Struct()
145 e.Str("icon")
146 tp := e.BeginArray('(')
147 e.EndArray(tp)
148 e.Str(f.tip[0])
149 e.Str(f.tip[1])
150 }
151 if f.menu != nil {
152 e.Struct()
153 e.Str("Menu")
154 e.Variant("o")
155 e.ObjectPath("/MenuBar")
156 }
157 if f.title != "" {
158 e.Struct()
159 e.Str("Title")
160 e.Variant("s")
161 e.Str(f.title)
162 }
163 e.Struct()
164 e.Str("IconPixmap")
165 e.Variant("a(iiay)")
166 px := e.BeginArray('(')
167 e.Struct()
168 e.Int32(1)
169 e.Int32(1)
170 e.ByteArray(f.pixel[:])
171 e.EndArray(px)
172 e.EndArray(a)
173 if _, err := f.c.Send(); err != nil {
174 return
175 }
176 }
177}
178
179// action records and answers one StatusNotifierItem method call. It reports
180// false when the connection is gone.
181func (f *fakeItem) action(m *zerobus.Message) bool {
182 r := m.Body()
183 var rec string
184 switch m.Member {
185 case "Activate", "SecondaryActivate", "ContextMenu":
186 x, y := r.Int32(), r.Int32()
187 rec = fmt.Sprintf("%s(%d,%d)", m.Member, x, y)
188 case "Scroll":
189 delta, orientation := r.Int32(), r.Str()
190 rec = fmt.Sprintf("Scroll(%d,%s)", delta, orientation)
191 default:
192 return true
193 }
194 f.calls <- rec
195 if m.Member == f.slow {
196 time.Sleep(f.slowFor)
197 }
198 if m.Member == f.unknown {
199 f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no such method")
200 } else {
201 f.c.NewReply(m, "")
202 }
203 _, err := f.c.Send()
204 return err == nil
205}
206
207// register registers the item with the watcher, by bus name.
208func (f *fakeItem) register(t *testing.T) {
209 f.run(t, func() {
210 f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name)
211 _, _ = f.c.Send()
212 })
213}
214
215// setTip changes the item's tooltip and emits NewToolTip.
216func (f *fakeItem) setTip(t *testing.T, title, body string) {
217 f.run(t, func() {
218 f.tip = &[2]string{title, body}
219 f.c.NewSignal(itemPath, itemIface, "NewToolTip", "")
220 _, _ = f.c.Send()
221 })
222}
223
224// setStatus changes the item's status and emits NewStatus.
225func (f *fakeItem) setStatus(t *testing.T, status string) {
226 f.run(t, func() {
227 f.cur[2] = status
228 f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status)
229 _, _ = f.c.Send()
230 })
231}
232
233// quit closes the item's connection, as when the application exits.
234func (f *fakeItem) quit(t *testing.T) {
235 f.run(t, func() { _ = f.c.Close() })
236 <-f.closed
237}
238
239// lines reads the tray's output.
240type lines struct {
241 ch chan string
242}
243
244func readLines(r io.Reader) *lines {
245 l := &lines{ch: make(chan string, 64)}
246 go func() {
247 sc := bufio.NewScanner(r)
248 for sc.Scan() {
249 l.ch <- sc.Text()
250 }
251 close(l.ch)
252 }()
253 return l
254}
255
256// waitFor returns the first line for which ok is true.
257func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string {
258 t.Helper()
259 timeout := time.After(5 * time.Second)
260 for {
261 select {
262 case s, open := <-l.ch:
263 if !open {
264 t.Fatalf("output ended while waiting for %s", what)
265 }
266 if ok(s) {
267 return s
268 }
269 case <-timeout:
270 t.Fatalf("timed out waiting for %s", what)
271 }
272 }
273}
274
275// startTray runs a tray on the bus. Cancel it and receive from the channel
276// to stop it; the test cleanup does both when the test did not.
277func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
278 return startTrayWith(t, addr, nil, nil)
279}
280
281// startTrayWith is startTray with the bar's input and a dialer for the
282// control connection.
283func startTrayWith(t *testing.T, addr string, in io.Reader, dialer func() (*zerobus.Conn, error)) (*lines, context.CancelFunc, <-chan error) {
284 pr, pw := io.Pipe()
285 ctx, cancel := context.WithCancel(context.Background())
286 done := make(chan error, 1)
287 var result error
288 stopped := make(chan struct{})
289 opt := Options{
290 Resolver: NewResolver(lookup, nil, []string{t.TempDir()}),
291 Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
292 Out: pw, In: in, Dial: dialer,
293 }
294 c := dial(t, addr)
295 go func() {
296 result = Run(ctx, c, opt)
297 _ = pw.Close()
298 close(stopped)
299 done <- result
300 }()
301 t.Cleanup(func() {
302 cancel()
303 <-stopped
304 })
305 return readLines(pr), cancel, done
306}
307
308func TestTrayAsWatcher(t *testing.T) {
309 addr := privateBus(t)
310 out, cancel, done := startTray(t, addr)
311 if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" {
312 t.Fatalf("first line %q, want empty", s)
313 }
314 // Wait until the tray owns the watcher name.
315 probe := dial(t, addr)
316 deadline := time.Now().Add(5 * time.Second)
317 for {
318 probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
319 m, err := probe.Call()
320 if err != nil {
321 t.Fatal(err)
322 }
323 if m.Body().Bool() {
324 break
325 }
326 if time.Now().After(deadline) {
327 t.Fatal("the tray did not become the watcher")
328 }
329 time.Sleep(10 * time.Millisecond)
330 }
331
332 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
333 steam.register(t)
334 line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") })
335 if !strings.Contains(line, "38;2;") {
336 t.Fatalf("the icon is not colored: %q", line)
337 }
338
339 steam.setStatus(t, "NeedsAttention")
340 out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") })
341
342 steam.setStatus(t, "Passive")
343 out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" })
344
345 steam.setStatus(t, "Active")
346 out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") })
347
348 // The application quits: its item goes away.
349 steam.quit(t)
350 out.waitFor(t, "the item to go away", func(s string) bool { return s == "" })
351
352 cancel()
353 if err := <-done; err != nil {
354 t.Fatalf("Run: %v", err)
355 }
356}
357
358// waitWatcher waits until the tray owns the watcher name.
359func waitWatcher(t *testing.T, addr string) {
360 t.Helper()
361 probe := dial(t, addr)
362 deadline := time.Now().Add(5 * time.Second)
363 for {
364 probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
365 m, err := probe.Call()
366 if err != nil {
367 t.Fatal(err)
368 }
369 if m.Body().Bool() {
370 return
371 }
372 if time.Now().After(deadline) {
373 t.Fatal("the tray did not become the watcher")
374 }
375 time.Sleep(10 * time.Millisecond)
376 }
377}
378
379// fakeWatcher is another panel's StatusNotifierWatcher. Like fakeItem, its
380// connection is used only by its own goroutine, which the test drives
381// through Ping.
382type fakeWatcher struct {
383 c *zerobus.Conn
384 probe *zerobus.Conn
385 items []string
386 cmds chan func()
387 closed chan struct{}
388}
389
390func newFakeWatcher(t *testing.T, addr string, flags uint32, items ...string) *fakeWatcher {
391 t.Helper()
392 w := &fakeWatcher{c: dial(t, addr), probe: dial(t, addr), items: items, cmds: make(chan func(), 1), closed: make(chan struct{})}
393 if code, err := w.c.RequestName(watcherName, flags|zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
394 t.Fatalf("RequestName = %d, %v", code, err)
395 }
396 go w.serve()
397 return w
398}
399
400func (w *fakeWatcher) serve() {
401 defer close(w.closed)
402 for {
403 m, err := w.c.ReadMessage()
404 if err != nil {
405 return
406 }
407 if m.Type != zerobus.TypeMethodCall {
408 continue
409 }
410 switch m.Member {
411 case "Ping":
412 w.c.NewReply(m, "")
413 if _, err := w.c.Send(); err != nil {
414 return
415 }
416 (<-w.cmds)()
417 continue
418 case "Get":
419 e := w.c.NewReply(m, "v")
420 e.Variant("as")
421 a := e.BeginArray('s')
422 for _, s := range w.items {
423 e.Str(s)
424 }
425 e.EndArray(a)
426 default:
427 w.c.NewReply(m, "")
428 }
429 if _, err := w.c.Send(); err != nil {
430 return
431 }
432 }
433}
434
435// do hands fn to the watcher's goroutine; see fakeItem.run.
436func (w *fakeWatcher) do(t *testing.T, fn func()) {
437 t.Helper()
438 w.cmds <- fn
439 w.probe.NewCall(w.c.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
440 if _, err := w.probe.Call(); err != nil {
441 t.Fatal(err)
442 }
443}
444
445func (w *fakeWatcher) emit(t *testing.T, member, service string) {
446 w.do(t, func() {
447 w.c.NewSignal(watcherPath, watcherIface, member, "s").Str(service)
448 _, _ = w.c.Send()
449 })
450}
451
452func (w *fakeWatcher) quit(t *testing.T) {
453 w.do(t, func() { _ = w.c.Close() })
454 <-w.closed
455}
456
457func TestTrayAsHost(t *testing.T) {
458 addr := privateBus(t)
459 obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
460 // Another program is the watcher, and lists one item.
461 w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
462
463 out, _, _ := startTray(t, addr)
464 out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
465
466 // A second item is announced by the other watcher, by bus name only.
467 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
468 w.emit(t, "StatusNotifierItemRegistered", steam.name)
469 out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
470
471 // And removed in the same form.
472 w.emit(t, "StatusNotifierItemUnregistered", steam.name)
473 out.waitFor(t, "steam to go away", func(s string) bool { return strings.Contains(s, "O") && !strings.Contains(s, "S") })
474}
475
476func TestHostFollowsReplacedWatcher(t *testing.T) {
477 addr := privateBus(t)
478 obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
479 newFakeWatcher(t, addr, zerobus.NameFlagAllowReplacement, obsidian.name+itemPath)
480 out, _, _ := startTray(t, addr)
481 out.waitFor(t, "the first watcher's item", func(s string) bool { return strings.Contains(s, "O") })
482
483 // A new panel replaces the watcher: its items are the ones to show.
484 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
485 newFakeWatcher(t, addr, zerobus.NameFlagReplaceExisting, steam.name+itemPath)
486 out.waitFor(t, "the new watcher's item only", func(s string) bool { return strings.Contains(s, "S") && !strings.Contains(s, "O") })
487}
488
489func TestHostTakesOverWatcher(t *testing.T) {
490 addr := privateBus(t)
491 obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
492 w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
493 out, cancel, done := startTray(t, addr)
494 out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
495
496 // The other panel exits: the tray drops its items and becomes the
497 // watcher, where items register again.
498 w.quit(t)
499 out.waitFor(t, "the items to go", func(s string) bool { return s == "" })
500 waitWatcher(t, addr)
501 obsidian.register(t)
502 out.waitFor(t, "the item registered again", func(s string) bool { return strings.Contains(s, "O") })
503
504 cancel()
505 if err := <-done; err != nil {
506 t.Fatalf("Run: %v", err)
507 }
508}
509
510// registered returns the watcher's RegisteredStatusNotifierItems.
511func registered(t *testing.T, c *zerobus.Conn) []string {
512 t.Helper()
513 e := c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
514 e.Str(watcherIface)
515 e.Str("RegisteredStatusNotifierItems")
516 m, err := c.Call()
517 if err != nil {
518 t.Fatal(err)
519 }
520 r := m.Body()
521 if v := r.Variant(); v != "as" {
522 t.Fatalf("variant %q", v)
523 }
524 var items []string
525 for end := r.Array('s'); r.More(end); {
526 items = append(items, strings.Clone(r.Str()))
527 }
528 return items
529}
530
531func TestWatcherRegistration(t *testing.T) {
532 addr := privateBus(t)
533 out, _, _ := startTray(t, addr)
534 waitWatcher(t, addr)
535
536 // libappindicator and Electron register an object path, not a name.
537 steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
538 steam.run(t, func() {
539 steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(itemPath)
540 _, _ = steam.c.Send()
541 })
542 out.waitFor(t, "the item registered by path", func(s string) bool { return strings.Contains(s, "S") })
543
544 // The same item again, by bus name: still one item.
545 dup := make(chan error, 1)
546 steam.run(t, func() {
547 steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(steam.name)
548 _, err := steam.c.Call()
549 dup <- err
550 })
551 if err := <-dup; err != nil {
552 t.Fatal(err)
553 }
554
555 // A path D-Bus refuses is rejected and leaves no trace.
556 probe := dial(t, addr)
557 for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} {
558 probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad)
559 if _, err := probe.Call(); err == nil {
560 t.Fatalf("%q was accepted", bad)
561 }
562 }
563 if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
564 t.Fatalf("registered items %q", items)
565 }
566}
567
568// TestRefreshNoAllocs checks the steady state: an item signals a change, the
569// tray asks for its properties and reads the reply. Nothing allocates once
570// the buffers have grown.
571func TestRefreshNoAllocs(t *testing.T) {
572 addr := privateBus(t)
573 app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
574 var out strings.Builder
575 tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
576 frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
577 tr.opt.Log = slog.New(slog.DiscardHandler)
578 tr.add(app.name, "")
579 cycle := func() {
580 tr.refresh(tr.items[0])
581 for len(tr.pending) > 0 {
582 m, err := tr.c.ReadMessage()
583 if err != nil {
584 t.Fatal(err)
585 }
586 tr.dispatch(m)
587 }
588 }
589 for range 3 {
590 cycle() // resolve the icon and grow the buffers
591 }
592 if tr.err != nil || !tr.items[0].loaded {
593 t.Fatalf("item not loaded: %v", tr.err)
594 }
595 if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
596 t.Fatalf("%v allocations per refresh, want 0", allocs)
597 }
598}
599
600func TestRenderSkipsUnchangedFrames(t *testing.T) {
601 var buf strings.Builder
602 tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
603 tr.items = []*item{{loaded: true, icon: "X"}}
604 for range 3 {
605 if err := tr.render(false); err != nil {
606 t.Fatal(err)
607 }
608 }
609 if got := strings.Count(buf.String(), "\n"); got != 1 {
610 t.Fatalf("%d frames written, want 1", got)
611 }
612 if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 {
613 t.Fatalf("render allocates %v times", allocs)
614 }
615}
616
617func TestBetterPixmapSize(t *testing.T) {
618 // The largest icon up to 64x64 wins; above that, the smallest.
619 for _, c := range []struct {
620 sizes []int // sides, in the order the item lists them
621 want int
622 }{
623 {[]int{16, 32, 128}, 32},
624 {[]int{128, 32, 16}, 32},
625 {[]int{256, 128}, 128},
626 {[]int{22}, 22},
627 } {
628 best := 0
629 for _, s := range c.sizes {
630 if best == 0 || better(s*s, best*best) {
631 best = s
632 }
633 }
634 if best != c.want {
635 t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want)
636 }
637 }
638}
639
640func TestRenderPublishesTargets(t *testing.T) {
641 var buf strings.Builder
642 tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
643 tr.items = []*item{
644 {loaded: true, icon: "S", dest: ":1.1", owner: ":1.1", path: "/a", menu: "/ma"},
645 {loaded: true, icon: "Z", status: passive, dest: ":1.9", path: "/z"},
646 {loaded: true, icon: "☀", dest: "org.x.Sun", owner: ":1.2", path: "/b", isMenu: true},
647 }
648 if err := tr.render(false); err != nil {
649 t.Fatal(err)
650 }
651 want := []Target{
652 {Dest: ":1.1", Path: "/a", Menu: "/ma", Start: 0, Width: 1},
653 {Dest: ":1.2", Path: "/b", IsMenu: true, Start: 2, Width: 1},
654 }
655 if got := tr.snap.targets; !slices.Equal(got, want) {
656 t.Fatalf("targets = %+v, want %+v", got, want)
657 }
658 for col, wantPath := range map[int]string{0: "/a", 2: "/b"} {
659 if tg, ok := tr.target(col); !ok || tg.Path != wantPath {
660 t.Errorf("target(%d) = %+v, %v; want %s", col, tg, ok, wantPath)
661 }
662 }
663 for _, col := range []int{-1, 1, 3} {
664 if _, ok := tr.target(col); ok {
665 t.Errorf("target(%d) found an icon in the gap or past the line", col)
666 }
667 }
668 // A hidden item leaves no target, and a wide icon moves the next one.
669 tr.items[0].icon = "界"
670 if err := tr.render(false); err != nil {
671 t.Fatal(err)
672 }
673 if got := tr.snap.targets; len(got) != 2 || got[0].Width != 2 || got[1].Start != 3 {
674 t.Fatalf("targets after a wide icon = %+v", got)
675 }
676}
677
678// A change of menu path alone republishes the targets without a new line.
679func TestRenderRepublishesTargetsWithoutNewLine(t *testing.T) {
680 var buf strings.Builder
681 tr := &Tray{opt: Options{Out: &buf}}
682 tr.items = []*item{{loaded: true, icon: "S", dest: ":1.1", path: "/a"}}
683 for range 2 {
684 if err := tr.render(false); err != nil {
685 t.Fatal(err)
686 }
687 }
688 tr.items[0].menu = "/menu"
689 if err := tr.render(false); err != nil {
690 t.Fatal(err)
691 }
692 if n := strings.Count(buf.String(), "\n"); n != 1 {
693 t.Fatalf("%d lines written, want 1", n)
694 }
695 if tg, _ := tr.target(0); tg.Menu != "/menu" {
696 t.Fatalf("target = %+v", tg)
697 }
698}