@@ -1,8 +1,11 @@
11package notification_test
22
33import (
4+ "encoding/base64"
5+ "fmt"
46 "testing"
57
8+ tea "charm.land/bubbletea/v2"
69 "github.com/charmbracelet/crush/internal/ui/notification"
710 "github.com/stretchr/testify/require"
811)
@@ -11,11 +14,11 @@ func TestNoopBackend_Send(t *testing.T) {
1114 t.Parallel()
1215
1316 backend := notification.NoopBackend{}
14− err := backend.Send(notification.Notification{
17+ cmd := backend.Send(notification.Notification{
1518 Title: "Test Title",
1619 Message: "Test Message",
1720 })
18− require.NoError(t, err)
21+ require.Nil(t, cmd)
1922}
2023
2124func TestNativeBackend_Send(t *testing.T) {
@@ -32,12 +35,164 @@ func TestNativeBackend_Send(t *testing.T) {
3235 return nil
3336 })
3437
35− err := backend.Send(notification.Notification{
38+ cmd := backend.Send(notification.Notification{
3639 Title: "Hello",
3740 Message: "World",
3841 })
39− require.NoError(t, err)
42+ require.NotNil(t, cmd)
43+ msg := cmd()
44+ require.Nil(t, msg)
4045 require.Equal(t, "Hello", capturedTitle)
4146 require.Equal(t, "World", capturedMessage)
4247 require.Nil(t, capturedIcon)
4348}
49+
50+func 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+
63+func TestOSC99Backend_Send(t *testing.T) {
64+ t.Parallel()
65+
66+ backend := notification.NewOSC99Backend(nil)
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+
81+func TestOSC99Backend_Send_TitleOnly(t *testing.T) {
82+ t.Parallel()
83+
84+ backend := notification.NewOSC99Backend(nil)
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+
95+func TestOSC99Backend_Send_WithIcon(t *testing.T) {
96+ t.Parallel()
97+
98+ iconData := []byte("fake-png-data")
99+ backend := notification.NewOSC99Backend(iconData)
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+
114+func TestOSC777Backend_Send(t *testing.T) {
115+ t.Parallel()
116+
117+ backend := notification.NewOSC777Backend()
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+
128+func 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+
136+func 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+
144+func 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+
152+func 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+
160+func 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+
168+func 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+
176+func TestDetectOSC99Support_EmptySequence(t *testing.T) {
177+ t.Parallel()
178+
179+ require.False(t, notification.DetectOSC99Support(""))
180+}
181+
182+func 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+
190+func 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+}