1package daemon
2
3import (
4 "encoding/base64"
5 "fmt"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/require"
11
12 renderer "github.com/bnema/vev-vt"
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/usecase/notices"
16 themeui "github.com/bnema/vev/internal/usecase/theme"
17)
18
19func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) {
20 terminal := renderer.DefaultStyle()
21 inactive := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 10, G: 20, B: 30}}
22 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 40, G: 50, B: 60}}
23 styles := themeui.Styles{
24 PickerBase: terminal, SurfaceInactive: inactive, PickerSelection: selection,
25 PickerSelectionName: selection, PickerSelectionMuted: selection,
26 PickerDescription: terminal,
27 }
28 model := notices.New([]domain.Notification{
29 {Code: domain.NoticePaneSpawn, Message: "selected", Time: time.Unix(2, 0)},
30 {Code: domain.NoticeTabSpawn, Message: "inactive", Time: time.Unix(1, 0)},
31 }, time.Unix(3, 0))
32 state := capturedRenderState{styles: styles, layout: capturedTabLayout{area: domain.Rect{Width: 100, Height: 38}}}
33 snap := &overlayRenderSnapshot{noticesOverlayActive: true, noticesOverlayModel: model}
34
35 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
36
37 inner := state.overlays.noticesOverlay.inner
38 require.True(t, inner.At(inner.Width-1, 1).Style.Equal(terminal), "inactive row keeps the terminal background")
39 require.True(t, inner.At(inner.Width-1, 2).Style.Equal(terminal), "unused interior keeps the terminal background")
40 require.True(t, inner.At(inner.Width-1, 0).Style.Equal(selection), "selected row keeps the accent background")
41}
42
43func TestNoticesOpenViaPaletteCommandShowsModalWithHistory(t *testing.T) {
44 p, release := newBlockingPTY(t)
45 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
46 defer release()
47 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Time: time.Unix(1, 0)})
48
49 d.enterPalette(sess, ac)
50 awaitFrame(t, sends, ports.MsgOutput)
51 d.handlePaletteInput(ac, []byte("NTC\r"))
52 out := awaitFrame(t, sends, ports.MsgOutput)
53
54 require.False(t, ac.overlays.paletteActive())
55 require.NotNil(t, ac.overlays.noticesOverlay)
56 msg, err := ports.UnmarshalOutput(out.Payload)
57 require.NoError(t, err)
58 require.Contains(t, string(msg.Data), "Notifications")
59 require.Contains(t, string(msg.Data), "pane-spawn")
60 require.Contains(t, string(msg.Data), "couldn't open pane")
61}
62
63func TestNoticesJKNavigatesSelection(t *testing.T) {
64 p, release := newBlockingPTY(t)
65 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
66 defer release()
67 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
68 d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
69 d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
70
71 d.enterNotices(sess, ac)
72 awaitFrame(t, sends, ports.MsgOutput)
73 selected, ok := ac.overlays.noticesOverlay.Selected()
74 require.True(t, ok)
75 require.Equal(t, "third", selected.Message)
76
77 d.handleInput(sess, ac, []byte("j"))
78 awaitFrame(t, sends, ports.MsgOutput)
79 selected, ok = ac.overlays.noticesOverlay.Selected()
80 require.True(t, ok)
81 require.Equal(t, "second", selected.Message)
82
83 d.handleInput(sess, ac, []byte("j"))
84 awaitFrame(t, sends, ports.MsgOutput)
85 selected, ok = ac.overlays.noticesOverlay.Selected()
86 require.True(t, ok)
87 require.Equal(t, "first", selected.Message)
88
89 // One more 'j' at the bottom must clamp rather than wrap.
90 d.handleInput(sess, ac, []byte("j"))
91 awaitFrame(t, sends, ports.MsgOutput)
92 selected, ok = ac.overlays.noticesOverlay.Selected()
93 require.True(t, ok)
94 require.Equal(t, "first", selected.Message)
95
96 d.handleInput(sess, ac, []byte("k"))
97 awaitFrame(t, sends, ports.MsgOutput)
98 selected, ok = ac.overlays.noticesOverlay.Selected()
99 require.True(t, ok)
100 require.Equal(t, "second", selected.Message)
101}
102
103func TestNoticesQAndCtrlCCloseImmediatelyAndClearState(t *testing.T) {
104 for _, key := range []byte{'q', 0x03} {
105 t.Run(fmt.Sprintf("key=%q", key), func(t *testing.T) {
106 p, release := newBlockingPTY(t)
107 defer release()
108 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
109 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
110
111 d.enterNotices(sess, ac)
112 awaitFrame(t, sends, ports.MsgOutput)
113 require.True(t, ac.overlays.noticesActive())
114
115 d.handleInput(sess, ac, []byte{key})
116 awaitFrame(t, sends, ports.MsgOutput)
117
118 require.False(t, ac.overlays.noticesActive())
119 require.Nil(t, ac.overlays.noticesOverlay)
120 })
121 }
122}
123
124func TestNoticesLoneEscapeClosesAfterDelay(t *testing.T) {
125 p, release := newBlockingPTY(t)
126 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
127 defer release()
128 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)})
129
130 d.enterNotices(sess, ac)
131 awaitFrame(t, sends, ports.MsgOutput)
132
133 clk := &signalClock{timers: make(chan *signalTimer, 1)}
134 d.clock = clk
135 d.handleInput(sess, ac, []byte("\x1b"))
136 timer := <-clk.timers
137 require.True(t, ac.overlays.noticesActive())
138
139 timer.ch <- time.Now()
140 require.Eventually(t, func() bool { return !ac.overlays.noticesActive() }, time.Second, 5*time.Millisecond)
141 awaitFrame(t, sends, ports.MsgOutput)
142}
143
144func TestNoticesSplitArrowNavigatesWithoutClosing(t *testing.T) {
145 cases := []struct {
146 name string
147 input [][]byte
148 want string
149 }{
150 {name: "escape then CSI down", input: [][]byte{[]byte("\x1b"), []byte("[B")}, want: "first"},
151 {name: "split CSI down", input: [][]byte{[]byte("\x1b["), []byte("B")}, want: "first"},
152 {name: "split SS3 down", input: [][]byte{[]byte("\x1bO"), []byte("B")}, want: "first"},
153 {name: "split CSI up", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, want: "second"},
154 }
155 for _, tc := range cases {
156 t.Run(tc.name, func(t *testing.T) {
157 p, release := newBlockingPTY(t)
158 defer release()
159 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
160 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
161 d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
162
163 d.enterNotices(sess, ac)
164 awaitFrame(t, sends, ports.MsgOutput)
165 for _, input := range tc.input {
166 d.handleInput(sess, ac, input)
167 }
168
169 require.True(t, ac.overlays.noticesActive())
170 selected, ok := ac.overlays.noticesOverlay.Selected()
171 require.True(t, ok)
172 require.Equal(t, tc.want, selected.Message)
173 })
174 }
175}
176
177func TestHandleListInputConsumesSplitEscapePrefix(t *testing.T) {
178 for _, tt := range []struct {
179 name string
180 prefix []byte
181 tail byte
182 wantUp int
183 wantDown int
184 }{
185 {name: "CSI up", prefix: []byte("\x1b["), tail: 'A', wantUp: 1},
186 {name: "CSI down", prefix: []byte("\x1b["), tail: 'B', wantDown: 1},
187 {name: "SS3 up", prefix: []byte("\x1bO"), tail: 'A', wantUp: 1},
188 {name: "SS3 down", prefix: []byte("\x1bO"), tail: 'B', wantDown: 1},
189 } {
190 t.Run(tt.name, func(t *testing.T) {
191 var pending, custom []byte
192 var up, down int
193 state := listInputState{
194 pending: &pending,
195 esc: &pendingByteTimer{},
196 moveUp: func() { up++ },
197 moveDown: func() { down++ },
198 }
199 action := func(b byte) listInputResult {
200 custom = append(custom, b)
201 return listInputResult{}
202 }
203
204 handleListInputLocked(nil, tt.prefix, state, action)
205 handleListInputLocked(nil, []byte{tt.tail}, state, action)
206
207 require.Equal(t, tt.wantUp, up)
208 require.Equal(t, tt.wantDown, down)
209 require.Empty(t, custom, "escape prefix bytes must not reach the custom action")
210 require.Empty(t, pending)
211 })
212 }
213}
214
215func TestHandleListInputConsumesUnsupportedCompleteEscapeSequences(t *testing.T) {
216 for _, tt := range []struct {
217 name string
218 input [][]byte
219 }{
220 {name: "complete CSI right", input: [][]byte{[]byte("\x1b[C")}},
221 {name: "complete SS3 left", input: [][]byte{[]byte("\x1bOD")}},
222 {name: "parameterized CSI", input: [][]byte{[]byte("\x1b[1;5C")}},
223 {name: "split CSI right", input: [][]byte{[]byte("\x1b["), []byte("C")}},
224 {name: "split SS3 left", input: [][]byte{[]byte("\x1bO"), []byte("D")}},
225 } {
226 t.Run(tt.name, func(t *testing.T) {
227 var pending, custom []byte
228 state := listInputState{
229 pending: &pending,
230 esc: &pendingByteTimer{},
231 moveUp: func() {},
232 moveDown: func() {},
233 }
234 action := func(b byte) listInputResult {
235 custom = append(custom, b)
236 return listInputResult{action: b}
237 }
238
239 var result listInputResult
240 for _, input := range tt.input {
241 result = handleListInputLocked(nil, input, state, action)
242 }
243
244 require.False(t, result.exit)
245 require.Zero(t, result.action)
246 require.Empty(t, custom, "complete escape bytes must not reach the custom action")
247 require.Empty(t, pending)
248 })
249 }
250}
251
252func TestHandleListInputStopsAfterExitInBatchedInput(t *testing.T) {
253 for _, tt := range []struct {
254 name string
255 input []byte
256 custom func(byte) listInputResult
257 wantExit bool
258 wantStop bool
259 wantAction byte
260 wantCalls []byte
261 }{
262 {
263 name: "q ignores trailing navigation and actions",
264 input: []byte("qjy"),
265 custom: func(b byte) listInputResult { return listInputResult{action: b} },
266 wantExit: true,
267 },
268 {
269 name: "ctrl-c ignores trailing navigation and actions",
270 input: []byte{0x03, 'j', 'y'},
271 custom: func(b byte) listInputResult { return listInputResult{action: b} },
272 wantExit: true,
273 },
274 {
275 name: "invalid escape exit ignores trailing navigation and actions",
276 input: []byte{'\x1b', 'x', 'j', 'y'},
277 custom: func(b byte) listInputResult { return listInputResult{action: b} },
278 wantExit: true,
279 wantCalls: nil,
280 wantAction: 0,
281 },
282 {
283 name: "custom exit retains action and ignores trailing bytes",
284 input: []byte("xjy"),
285 custom: func(b byte) listInputResult {
286 if b == 'x' {
287 return listInputResult{action: b, exit: true}
288 }
289 return listInputResult{action: b}
290 },
291 wantExit: true, wantAction: 'x', wantCalls: []byte("x"),
292 },
293 {
294 name: "custom stop retains action and ignores trailing bytes",
295 input: []byte("xjy"),
296 custom: func(b byte) listInputResult {
297 if b == 'x' {
298 return listInputResult{action: b, stop: true}
299 }
300 return listInputResult{action: b}
301 },
302 wantStop: true, wantAction: 'x', wantCalls: []byte("x"),
303 },
304 {
305 name: "custom exit and stop retain both flags and action",
306 input: []byte("xjy"),
307 custom: func(b byte) listInputResult {
308 if b == 'x' {
309 return listInputResult{action: b, exit: true, stop: true}
310 }
311 return listInputResult{action: b}
312 },
313 wantExit: true, wantStop: true, wantAction: 'x', wantCalls: []byte("x"),
314 },
315 } {
316 t.Run(tt.name, func(t *testing.T) {
317 var pending, calls []byte
318 var down int
319 state := listInputState{
320 pending: &pending,
321 esc: &pendingByteTimer{},
322 moveUp: func() {},
323 moveDown: func() { down++ },
324 }
325
326 result := handleListInputLocked(nil, tt.input, state, func(b byte) listInputResult {
327 calls = append(calls, b)
328 return tt.custom(b)
329 })
330
331 require.Equal(t, tt.wantExit, result.exit)
332 require.Equal(t, tt.wantStop, result.stop)
333 require.Equal(t, tt.wantAction, result.action)
334 require.Equal(t, tt.wantCalls, calls)
335 require.Zero(t, down, "trailing j must not be processed")
336 })
337 }
338}
339
340func TestNoticesOpenWithEmptyHistoryShowsPlaceholderWithoutPanic(t *testing.T) {
341 p, release := newBlockingPTY(t)
342 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
343 defer release()
344
345 require.NotPanics(t, func() { d.enterNotices(sess, ac) })
346 out := awaitFrame(t, sends, ports.MsgOutput)
347
348 require.NotNil(t, ac.overlays.noticesOverlay)
349 msg, err := ports.UnmarshalOutput(out.Payload)
350 require.NoError(t, err)
351 require.Contains(t, string(msg.Data), "Notifications")
352 require.Contains(t, string(msg.Data), "No notifications")
353}
354
355func TestNoticeYankPayload(t *testing.T) {
356 at := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
357 tests := []struct {
358 name string
359 n domain.Notification
360 want string
361 }{
362 {
363 name: "single occurrence, no details",
364 n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeInfo, Message: "opened pane", Time: at},
365 want: "[2024-01-02T03:04:05Z] info pane-spawn\nopened pane\n",
366 },
367 {
368 name: "coalesced count and details",
369 n: domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom ← permission denied", Count: 3, Time: at},
370 want: "[2024-01-02T03:04:05Z] error pane-spawn ×3\ncouldn't open pane\ndetails: boom ← permission denied\n",
371 },
372 {
373 name: "warn severity",
374 n: domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeWarn, Message: "selection too large", Time: at},
375 want: "[2024-01-02T03:04:05Z] warn clipboard\nselection too large\n",
376 },
377 }
378 for _, tt := range tests {
379 t.Run(tt.name, func(t *testing.T) {
380 require.Equal(t, tt.want, noticeYankPayload(tt.n))
381 })
382 }
383}
384
385func TestYankLastNotificationCommandCopiesOSC52AndShowsFeedback(t *testing.T) {
386 p, release := newBlockingPTY(t)
387 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
388 defer release()
389 n := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeError, Message: "couldn't open pane", Details: "boom", Time: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}
390 d.notices.record(n)
391 want := noticeYankPayload(n)
392
393 d.enterPalette(sess, ac)
394 awaitFrame(t, sends, ports.MsgOutput)
395 d.handlePaletteInput(ac, []byte("YLN\r"))
396
397 out := awaitFrame(t, sends, ports.MsgOutput)
398 msg, err := ports.UnmarshalOutput(out.Payload)
399 require.NoError(t, err)
400 payload := string(msg.Data)
401 require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
402 require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
403 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
404 require.NoError(t, err)
405 require.Equal(t, want, string(decoded))
406
407 live := awaitFrame(t, sends, ports.MsgOutput)
408 liveMsg, err := ports.UnmarshalOutput(live.Payload)
409 require.NoError(t, err)
410 require.Contains(t, string(liveMsg.Data), "copied notification details")
411}
412
413func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
414 p, release := newBlockingPTY(t)
415 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
416 defer release()
417
418 d.enterPalette(sess, ac)
419 awaitFrame(t, sends, ports.MsgOutput)
420 d.handlePaletteInput(ac, []byte("YLN\r"))
421
422 out := awaitFrame(t, sends, ports.MsgOutput)
423 msg, err := ports.UnmarshalOutput(out.Payload)
424 require.NoError(t, err)
425 require.Contains(t, string(msg.Data), "no notifications yet")
426
427 history := d.notices.history()
428 require.Len(t, history, 1)
429 require.Equal(t, domain.NoticeClipboard, history[0].Code)
430 require.Equal(t, domain.NoticeWarn, history[0].Severity)
431 require.Equal(t, "no notifications yet", history[0].Message)
432}
433
434func TestNoticesYKeyYanksSelectedNotification(t *testing.T) {
435 p, release := newBlockingPTY(t)
436 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
437 defer release()
438 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "first", Time: time.Unix(1, 0)})
439 d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Severity: domain.NoticeError, Message: "second", Time: time.Unix(2, 0)})
440
441 d.enterNotices(sess, ac)
442 awaitFrame(t, sends, ports.MsgOutput)
443 selected, ok := ac.overlays.noticesOverlay.Selected()
444 require.True(t, ok)
445 require.Equal(t, "second", selected.Message)
446 want := noticeYankPayload(selected)
447
448 d.handleInput(sess, ac, []byte("y"))
449
450 out := awaitFrame(t, sends, ports.MsgOutput)
451 msg, err := ports.UnmarshalOutput(out.Payload)
452 require.NoError(t, err)
453 payload := string(msg.Data)
454 require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
455 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
456 require.NoError(t, err)
457 require.Equal(t, want, string(decoded))
458
459 // The overlay stays open after a quick yank; it isn't a modal commit like
460 // copy mode's own 'y'.
461 require.True(t, ac.overlays.noticesActive())
462}
463
464func TestNoticesBatchedYankCapturesSelectionAtY(t *testing.T) {
465 tests := []struct {
466 name string
467 input []byte
468 wantYanked string
469 wantSelected string
470 }{
471 {name: "yank then down", input: []byte("yj"), wantYanked: "third", wantSelected: "second"},
472 {name: "down then yank", input: []byte("jy"), wantYanked: "second", wantSelected: "second"},
473 }
474 for _, tt := range tests {
475 t.Run(tt.name, func(t *testing.T) {
476 p, release := newBlockingPTY(t)
477 defer release()
478 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
479 d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "first", Time: time.Unix(1, 0)})
480 d.notices.record(domain.Notification{Code: domain.NoticeTabSpawn, Message: "second", Time: time.Unix(2, 0)})
481 d.notices.record(domain.Notification{Code: domain.NoticeInternal, Message: "third", Time: time.Unix(3, 0)})
482
483 d.enterNotices(sess, ac)
484 awaitFrame(t, sends, ports.MsgOutput)
485 d.handleInput(sess, ac, tt.input)
486
487 out := awaitFrame(t, sends, ports.MsgOutput)
488 msg, err := ports.UnmarshalOutput(out.Payload)
489 require.NoError(t, err)
490 payload := strings.TrimSuffix(strings.TrimPrefix(string(msg.Data), "\x1b]52;c;"), "\a")
491 decoded, err := base64.StdEncoding.DecodeString(payload)
492 require.NoError(t, err)
493 require.Contains(t, string(decoded), "\n"+tt.wantYanked+"\n")
494
495 selected, ok := ac.overlays.noticesOverlay.Selected()
496 require.True(t, ok)
497 require.Equal(t, tt.wantSelected, selected.Message)
498 })
499 }
500}
501
502func TestNoticesYKeyWithNoSelectionIsNoOp(t *testing.T) {
503 p, release := newBlockingPTY(t)
504 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
505 defer release()
506
507 require.NotPanics(t, func() { d.enterNotices(sess, ac) })
508 awaitFrame(t, sends, ports.MsgOutput)
509
510 require.NotPanics(t, func() { d.handleInput(sess, ac, []byte("y")) })
511 require.True(t, ac.overlays.noticesActive())
512}