//go:build linux package test // Process-boundary test for per-announcement notifications with a Cancel // button: a private D-Bus session bus (dbus-daemon) hosts a fake // org.freedesktop.Notifications service written here in Go; the daemon // connects to it via DBUS_SESSION_BUS_ADDRESS. Observed: Notify arrives with // the announcement text and the cancel action, ActionInvoked("cancel") // aborts the playing synthesis, and CloseNotification follows. import ( "bufio" "os/exec" "strings" "sync" "testing" "time" "github.com/godbus/dbus/v5" ) // startPrivateBus starts a dbus-daemon session bus and returns its address. func startPrivateBus(t *testing.T) string { t.Helper() if _, err := exec.LookPath("dbus-daemon"); err != nil { t.Skip("dbus-daemon not installed") } cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1") stdout, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } if err := cmd.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) line, err := bufio.NewReader(stdout).ReadString('\n') if err != nil { t.Fatalf("dbus-daemon printed no address: %v", err) } return strings.TrimSpace(line) } // notifyCall is one Notify the fake service received. type notifyCall struct { id uint32 summary string body string actions []string expire int32 } // fakeNotifications is a minimal org.freedesktop.Notifications service. type fakeNotifications struct { conn *dbus.Conn mu sync.Mutex next uint32 calls []notifyCall closed []uint32 got chan notifyCall } func (f *fakeNotifications) Notify(appName string, replaces uint32, icon, summary, body string, actions []string, hints map[string]dbus.Variant, expire int32) (uint32, *dbus.Error) { f.mu.Lock() f.next++ c := notifyCall{id: f.next, summary: summary, body: body, actions: actions, expire: expire} f.calls = append(f.calls, c) f.mu.Unlock() f.got <- c return c.id, nil } func (f *fakeNotifications) CloseNotification(id uint32) *dbus.Error { f.mu.Lock() f.closed = append(f.closed, id) f.mu.Unlock() _ = f.conn.Emit("/org/freedesktop/Notifications", "org.freedesktop.Notifications.NotificationClosed", id, uint32(3)) return nil } func (f *fakeNotifications) GetCapabilities() ([]string, *dbus.Error) { return []string{"body", "actions"}, nil } func (f *fakeNotifications) GetServerInformation() (string, string, string, string, *dbus.Error) { return "fake-notifications", "talk-tests", "1.0", "1.2", nil } func (f *fakeNotifications) invoke(id uint32, action string) error { return f.conn.Emit("/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", id, action) } func (f *fakeNotifications) closedIDs() []uint32 { f.mu.Lock() defer f.mu.Unlock() return append([]uint32(nil), f.closed...) } // serveFakeNotifications registers the fake service on the bus at addr. func serveFakeNotifications(t *testing.T, addr string) *fakeNotifications { t.Helper() conn, err := dbus.Connect(addr) if err != nil { t.Fatalf("connecting to the private bus: %v", err) } t.Cleanup(func() { conn.Close() }) f := &fakeNotifications{conn: conn, got: make(chan notifyCall, 8)} if err := conn.Export(f, "/org/freedesktop/Notifications", "org.freedesktop.Notifications"); err != nil { t.Fatal(err) } reply, err := conn.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue) if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { t.Fatalf("owning org.freedesktop.Notifications: %v (%v)", err, reply) } return f } // TestNotificationWithCancelButton: a playing announcement shows a // notification carrying the text and a Cancel action; clicking it aborts the // synthesis (the fake provider sees the request cancelled) and the // notification is closed. A second, uninterrupted announcement is closed // when it finishes. func TestNotificationWithCancelButton(t *testing.T) { addr := startPrivateBus(t) fakeNotif := serveFakeNotifications(t, addr) root := t.TempDir() fake := newFakeProvider(t, 5*time.Second) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_NOTIFY": "true", "DBUS_SESSION_BUS_ADDRESS": addr, }) code, m := speak(t, d, `{"text":"Der lange Vortrag beginnt jetzt","lang":"de"}`) if code != 202 || m["id"] == "" { t.Fatalf("speak: %d %v", code, m) } var call notifyCall select { case call = <-fakeNotif.got: case <-time.After(5 * time.Second): t.Fatalf("no Notify reached the fake service; daemon log:\n%s", d.stderr.String()) } if call.summary != "talk" || call.body != "Der lange Vortrag beginnt jetzt" { t.Errorf("Notify = %+v, want summary talk and the announcement text", call) } if strings.Join(call.actions, "|") != "cancel|Abbrechen" { t.Errorf("actions = %v, want [cancel Abbrechen] (German label for lang de)", call.actions) } if call.expire != 0 { t.Errorf("expire_timeout = %d, want 0 (stays until talk closes it)", call.expire) } fake.waitArrived(t, 1) // synthesis is in flight (5 s fake delay) // Click Cancel: the synthesis request is aborted, the daemon goes idle. if err := fakeNotif.invoke(call.id, "cancel"); err != nil { t.Fatal(err) } hits := fake.waitHits(t, 1) if hits[0].Completed { t.Errorf("synthesis completed although Cancel was clicked: %+v", hits[0]) } waitStatusIdle(t, d, 3*time.Second) if !strings.Contains(d.stderr.String(), "cancelled from its notification") { t.Errorf("daemon log lacks the cancel line:\n%s", d.stderr.String()) } deadline := time.Now().Add(2 * time.Second) for len(fakeNotif.closedIDs()) == 0 && time.Now().Before(deadline) { time.Sleep(20 * time.Millisecond) } if ids := fakeNotif.closedIDs(); len(ids) != 1 || ids[0] != call.id { t.Errorf("CloseNotification ids = %v, want [%d]", ids, call.id) } // Second announcement runs through; its notification is closed at the end // and carries the English label for an English text. fake.setDelay(100 * time.Millisecond) speak(t, d, `{"text":"The second one finishes on its own"}`) select { case call = <-fakeNotif.got: case <-time.After(5 * time.Second): t.Fatal("no Notify for the second announcement") } if strings.Join(call.actions, "|") != "cancel|Cancel" { t.Errorf("actions = %v, want [cancel Cancel]", call.actions) } fake.waitCompleted(t, 1) waitStatusIdle(t, d, 3*time.Second) deadline = time.Now().Add(2 * time.Second) for len(fakeNotif.closedIDs()) < 2 && time.Now().Before(deadline) { time.Sleep(20 * time.Millisecond) } if ids := fakeNotif.closedIDs(); len(ids) != 2 || ids[1] != call.id { t.Errorf("CloseNotification ids = %v, want the second id %d appended", ids, call.id) } } // TestNotificationsOffByConfig: TALK_NOTIFY=false shows nothing even with a // notification service around. func TestNotificationsOffByConfig(t *testing.T) { addr := startPrivateBus(t) fakeNotif := serveFakeNotifications(t, addr) root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_NOTIFY": "false", "DBUS_SESSION_BUS_ADDRESS": addr, }) speak(t, d, `{"text":"silent on the desktop"}`) fake.waitCompleted(t, 1) select { case c := <-fakeNotif.got: t.Errorf("Notify received although notifications are off: %+v", c) case <-time.After(300 * time.Millisecond): } }