Files
m3ta-chironandClaude Fable 5.1 2d9d2a86e9
release / release (push) Canceled after 0s
test / test (push) Canceled after 0s
feat(notify): Windows toasts under talk's own identity, icon on Linux
The daemon (and talk test --toast) now register the AppUserModelID
m3ta.talk under HKCU with display name and the installed icon, so
toasts appear as "talk" with the icon instead of Windows PowerShell;
the PowerShell identity stays as fallback until the registration has
run once. The redundant "talk" title line is dropped under the own
identity. notify.Setup bundles icon install, identity and the talk:
protocol handler for both callers. Linux notifications carry the
installed icon as app_icon. doctor names the active identity.

Verified on the Windows 11 VM: registry key, icon file, toast with
working Cancel button, exe icon; Linux via quickshell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:43:14 +01:00

133 lines
4.3 KiB
Go

package notify
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestMain cuts the tests off from the developer's real session bus so they
// observe the notify-send fallback instead of popping real notifications.
func TestMain(m *testing.M) {
os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/talk-test-bus")
os.Exit(m.Run())
}
// TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a
// fake in PATH that records its arguments (a real process, no mock
// framework). Title and text arrive unchanged.
func TestShowCallsNotifySend(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("the notify-send path exists only on Linux")
}
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"" + seen + "\"\n"
if err := os.WriteFile(filepath.Join(dir, "notify-send"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
if !Available() {
t.Fatal("Available = false with fake in PATH")
}
if err := Show(context.Background(), "quiet hours: build done"); err != nil {
t.Fatalf("Show: %v", err)
}
b, err := os.ReadFile(seen)
if err != nil {
t.Fatal(err)
}
args := strings.Split(strings.TrimSpace(string(b)), "\n")
if args[len(args)-1] != "quiet hours: build done" || args[len(args)-2] != Title {
t.Errorf("notify-send arguments = %q, want ... %q %q", args, Title, "quiet hours: build done")
}
}
// TestShowWithoutTool: a missing tool is a clear error.
func TestShowWithoutTool(t *testing.T) {
t.Setenv("PATH", t.TempDir())
if Available() {
t.Fatal("Available = true without tool")
}
if err := Show(context.Background(), "x"); err == nil {
t.Error("Show without tool: no error")
}
if !strings.Contains(Describe(), "not found") {
t.Errorf("Describe = %q", Describe())
}
}
func TestAppleString(t *testing.T) {
if got := appleString(`say "hello" \ bye`); got != `"say \"hello\" \\ bye"` {
t.Errorf("appleString = %s", got)
}
}
// TestToastIdentity: under talk's own AppUserModelID the toast header names
// talk already, so the scripts carry only the body; under the PowerShell
// fallback identity a first line names talk.
func TestToastIdentity(t *testing.T) {
a := Announcement{Text: "build done", CancelLabel: "Cancel", CancelURI: "talk:cancel?id=1", Tag: "1"}
own := cancelToastScript(a, "talk-1", AppID)
if !strings.Contains(own, "CreateToastNotifier('"+AppID+"')") || strings.Contains(own, "<text>"+Title+"</text>") {
t.Errorf("own identity script wrong:\n%s", own)
}
fallback := cancelToastScript(a, "talk-1", powerShellAppID)
if !strings.Contains(fallback, "CreateToastNotifier('"+powerShellAppID+"')") || !strings.Contains(fallback, "<text>"+Title+"</text>") {
t.Errorf("fallback script wrong:\n%s", fallback)
}
plain := toastScript("quiet", AppID)
if strings.Contains(plain, "<text>"+Title+"</text>") || !strings.Contains(plain, "<text>$msg</text>") {
t.Errorf("plain own-identity script wrong:\n%s", plain)
}
if !strings.Contains(toastScript("quiet", powerShellAppID), "<text>"+Title+"</text>") {
t.Error("plain fallback script lacks the talk line")
}
}
// TestUseIconOutsideWindows: UseIcon remembers the file and the freedesktop
// notification carries it as app_icon; without one a theme icon is used.
func TestUseIconOutsideWindows(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("registry path on Windows")
}
setIconPath("")
if appIcon() != "audio-speakers" {
t.Errorf("appIcon without file = %q", appIcon())
}
if err := UseIcon("/x/talk.png"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { setIconPath("") })
if appIcon() != "/x/talk.png" {
t.Errorf("appIcon = %q", appIcon())
}
if ownIdentity() {
t.Error("ownIdentity true outside Windows")
}
}
// TestSetup: the icon lands in dir and is used; with no exe the protocol
// step is skipped (outside Windows it is a no-op anyway).
func TestSetup(t *testing.T) {
dir := t.TempDir()
if err := Setup(dir, ""); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { setIconPath("") })
want := filepath.Join(dir, "talk.png")
if _, err := os.Stat(want); err != nil {
t.Fatalf("icon not installed: %v", err)
}
if runtime.GOOS != "windows" && appIcon() != want {
t.Errorf("appIcon = %q, want %q", appIcon(), want)
}
if err := Setup("", ""); err == nil {
t.Error("Setup without dir succeeded")
}
}