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}