notification_test.go

  1package notification_test
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"testing"
  7
  8	tea "charm.land/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/ui/notification"
 10	"github.com/stretchr/testify/require"
 11)
 12
 13func TestNoopBackend_Send(t *testing.T) {
 14	t.Parallel()
 15
 16	backend := notification.NoopBackend{}
 17	cmd := backend.Send(notification.Notification{
 18		Title:   "Test Title",
 19		Message: "Test Message",
 20	})
 21	require.Nil(t, cmd)
 22}
 23
 24func TestNativeBackend_Send(t *testing.T) {
 25	t.Parallel()
 26
 27	backend := notification.NewNativeBackend(nil)
 28
 29	var capturedTitle, capturedMessage string
 30	var capturedIcon any
 31	backend.SetNotifyFunc(func(title, message string, icon any) error {
 32		capturedTitle = title
 33		capturedMessage = message
 34		capturedIcon = icon
 35		return nil
 36	})
 37
 38	cmd := backend.Send(notification.Notification{
 39		Title:   "Hello",
 40		Message: "World",
 41	})
 42	require.NotNil(t, cmd)
 43	msg := cmd()
 44	require.Nil(t, msg)
 45	require.Equal(t, "Hello", capturedTitle)
 46	require.Equal(t, "World", capturedMessage)
 47	require.Nil(t, capturedIcon)
 48}
 49
 50func extractRawString(t *testing.T, cmd tea.Cmd) string {
 51	t.Helper()
 52	require.NotNil(t, cmd)
 53
 54	msg := cmd()
 55	raw, ok := msg.(tea.RawMsg)
 56	require.True(t, ok)
 57
 58	s, ok := raw.Msg.(string)
 59	require.True(t, ok)
 60	return s
 61}
 62
 63func TestOSCBackend_Send_OSC99(t *testing.T) {
 64	t.Parallel()
 65
 66	backend := notification.NewOSCBackend(nil, true)
 67	s := extractRawString(t, backend.Send(notification.Notification{
 68		Title:   "Crush is waiting...",
 69		Message: "Agent's turn completed",
 70	}))
 71
 72	require.Contains(t, s, "p=title")
 73	require.Contains(t, s, "p=body")
 74	require.Contains(t, s, "Crush is waiting...")
 75	require.Contains(t, s, "Agent's turn completed")
 76	require.NotContains(t, s, "p=icon")
 77	require.NotContains(t, s, "\x1b]777;")
 78	require.NotContains(t, s, "\x1b]9;")
 79}
 80
 81func TestOSCBackend_Send_OSC99_TitleOnly(t *testing.T) {
 82	t.Parallel()
 83
 84	backend := notification.NewOSCBackend(nil, true)
 85	s := extractRawString(t, backend.Send(notification.Notification{
 86		Title: "Crush is waiting...",
 87	}))
 88
 89	require.Contains(t, s, "p=title")
 90	require.NotContains(t, s, "p=body")
 91	require.NotContains(t, s, "\x1b]777;")
 92	require.NotContains(t, s, "\x1b]9;")
 93}
 94
 95func TestOSCBackend_Send_OSC99_WithIcon(t *testing.T) {
 96	t.Parallel()
 97
 98	iconData := []byte("fake-png-data")
 99	backend := notification.NewOSCBackend(iconData, true)
100	s := extractRawString(t, backend.Send(notification.Notification{
101		Title:   "Test",
102		Message: "With icon",
103	}))
104
105	require.Contains(t, s, "p=icon")
106	require.Contains(t, s, "e=1")
107
108	encoded := base64.StdEncoding.EncodeToString(iconData)
109	require.Contains(t, s, fmt.Sprintf(";%s\x07", encoded))
110	require.NotContains(t, s, "\x1b]777;")
111	require.NotContains(t, s, "\x1b]9;")
112}
113
114func TestOSCBackend_Send_OSC777(t *testing.T) {
115	t.Parallel()
116
117	backend := notification.NewOSCBackend(nil, false)
118	s := extractRawString(t, backend.Send(notification.Notification{
119		Title:   "Test",
120		Message: "With body",
121	}))
122
123	require.Equal(t, "\x1b]777;notify;Test;With body\x07", s)
124	require.NotContains(t, s, "\x1b]99;")
125	require.NotContains(t, s, "\x1b]9;")
126}
127
128func TestDetectOSC99Support_ValidResponse(t *testing.T) {
129	t.Parallel()
130
131	// Simulate a valid OSC 99 response with title support.
132	seq := "\x1b]99;i=crush-osc99-query:p=?;p=title\x07"
133	require.True(t, notification.DetectOSC99Support(seq))
134}
135
136func TestDetectOSC99Support_MultipleCapabilities(t *testing.T) {
137	t.Parallel()
138
139	// Response indicating support for title, body, and icon.
140	seq := "\x1b]99;i=crush-osc99-query:p=?;p=title,body,icon\x07"
141	require.True(t, notification.DetectOSC99Support(seq))
142}
143
144func TestDetectOSC99Support_InvalidCommand(t *testing.T) {
145	t.Parallel()
146
147	// OSC 98 instead of 99.
148	seq := "\x1b]98;i=crush-osc99-query:p=?;p=title\x07"
149	require.False(t, notification.DetectOSC99Support(seq))
150}
151
152func TestDetectOSC99Support_WrongQueryID(t *testing.T) {
153	t.Parallel()
154
155	// Correct OSC 99 but wrong query ID.
156	seq := "\x1b]99;i=some-other-id:p=?;p=title\x07"
157	require.False(t, notification.DetectOSC99Support(seq))
158}
159
160func TestDetectOSC99Support_NoQueryFlag(t *testing.T) {
161	t.Parallel()
162
163	// Missing p=? query flag.
164	seq := "\x1b]99;i=crush-osc99-query;p=title\x07"
165	require.False(t, notification.DetectOSC99Support(seq))
166}
167
168func TestDetectOSC99Support_NoTitleCapability(t *testing.T) {
169	t.Parallel()
170
171	// Response without title capability (only body).
172	seq := "\x1b]99;i=crush-osc99-query:p=?;p=body\x07"
173	require.False(t, notification.DetectOSC99Support(seq))
174}
175
176func TestDetectOSC99Support_EmptySequence(t *testing.T) {
177	t.Parallel()
178
179	require.False(t, notification.DetectOSC99Support(""))
180}
181
182func TestDetectOSC99Support_MalformedSequence(t *testing.T) {
183	t.Parallel()
184
185	// Missing semicolon separator.
186	seq := "\x1b]99;i=crush-osc99-query:p=?p=title\x07"
187	require.False(t, notification.DetectOSC99Support(seq))
188}
189
190func TestOSC99QuerySequence(t *testing.T) {
191	t.Parallel()
192
193	seq := notification.OSC99QuerySequence()
194	require.Contains(t, seq, "\x1b]99;")
195	require.Contains(t, seq, "i=crush-osc99-query")
196	require.Contains(t, seq, "p=?")
197	require.Contains(t, seq, "\x07")
198}
199
200func TestBellBackend_Send(t *testing.T) {
201	t.Parallel()
202
203	backend := notification.NewBellBackend()
204	s := extractRawString(t, backend.Send(notification.Notification{
205		Title:   "Test",
206		Message: "Ignored by bell",
207	}))
208
209	// Bell backend only sends the bell character.
210	require.Equal(t, "\x07", s)
211	require.NotContains(t, s, "Test")
212	require.NotContains(t, s, "Ignored")
213}