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