1package daemon
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14
15 vt "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19)
20
21func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {
22 d := newTestDaemon(t, nil, stubClock{})
23 local := &session{sessionCore: sessionCore{id: "local", name: "local"}}
24 d.reportAttachmentError(local, domain.UserWarn(domain.NoticeClipboard, "local failure", nil))
25
26 history := d.notices.history()
27 require.Len(t, history, 1)
28 require.Equal(t, local.id, history[0].SessionID)
29}
30
31func TestClientNoticeMapsFixedActionsAndDismissesOnlyConnectionToast(t *testing.T) {
32 d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{})
33 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardFallback})
34 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeClipboardTooLarge})
35 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkDegraded})
36
37 toasts, _ := visibleToasts(ac)
38 require.Len(t, toasts, 3)
39 require.Equal(t, []domain.Notification{
40 {Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "connection degraded", SessionID: sess.id, Count: 1},
41 {Code: domain.NoticeClipboardTooLarge, Severity: domain.NoticeWarn, Message: "image too large to paste", SessionID: sess.id, Count: 1},
42 {Code: domain.NoticeClipboard, Severity: domain.NoticeError, Message: "image paste failed; sent Ctrl+V", SessionID: sess.id, Count: 1},
43 }, stripNoticeTimes(toasts))
44
45 d.handleClientNotice(sess, ac, ports.ClientNotice{Action: ports.ClientNoticeLinkConnected})
46 toasts, _ = visibleToasts(ac)
47 require.Len(t, toasts, 2)
48 for _, toast := range toasts {
49 require.NotEqual(t, domain.NoticeConnection, toast.Code)
50 }
51}
52
53func TestMalformedClientNoticeIsIgnored(t *testing.T) {
54 d, sess, ac, _ := newNoticeFixture(t, ¬iceClock{})
55 d.attachCoordinator(sess, nil, ac, true)
56 tr, ok := ac.tr.(*portsmocks.MockTransport)
57 require.True(t, ok, "attached client transport must be a MockTransport")
58 frames := []ports.Frame{
59 {Type: ports.MsgClientNotice, Payload: []byte{0xff}},
60 {Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})},
61 }
62 tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
63 frame := frames[0]
64 frames = frames[1:]
65 return frame, nil
66 }).Times(2)
67 d.runConnLoop(ac)
68 toasts, _ := visibleToasts(ac)
69 require.Empty(t, toasts)
70 require.Empty(t, d.notices.history())
71}
72
73func stripNoticeTimes(notices []domain.Notification) []domain.Notification {
74 out := append([]domain.Notification(nil), notices...)
75 for i := range out {
76 out[i].Time = time.Time{}
77 }
78 return out
79}
80
81func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
82 return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid}
83}
84
85func TestNoticeCenterRingEviction(t *testing.T) {
86 nc := newNoticeCenter()
87 for i := 0; i < 205; i++ {
88 nc.record(notice(domain.NoticeInternal, fmt.Sprintf("n%d", i), ""))
89 }
90 h := nc.history()
91 if len(h) != 200 {
92 t.Fatalf("history len = %d, want 200", len(h))
93 }
94 if h[0].Message != "n204" {
95 t.Fatalf("history[0] = %q, want n204 (newest insert)", h[0].Message)
96 }
97 if h[199].Message != "n5" {
98 t.Fatalf("history[199] = %q, want n5 (oldest retained after eviction)", h[199].Message)
99 }
100 // Every slot must hold exactly the expected message: strictly newest-first,
101 // no gaps, and none of the evicted n0..n4 leaked back in.
102 for i, n := range h {
103 want := fmt.Sprintf("n%d", 204-i)
104 if n.Message != want {
105 t.Fatalf("history[%d] = %q, want %q", i, n.Message, want)
106 }
107 }
108}
109
110func TestNoticeCenterHistoryNewestFirst(t *testing.T) {
111 nc := newNoticeCenter()
112 nc.record(notice(domain.NoticePaneSpawn, "first", ""))
113 nc.record(notice(domain.NoticeTabSpawn, "second", ""))
114 h := nc.history()
115 if h[0].Message != "second" || h[1].Message != "first" {
116 t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message)
117 }
118 last, ok := nc.latest()
119 if !ok || last.Message != "second" {
120 t.Fatalf("latest = %v,%v", last.Message, ok)
121 }
122}
123
124func TestNoticeCenterPendingQueue(t *testing.T) {
125 t.Run("dedup coalesces by code without growing the queue", func(t *testing.T) {
126 nc := newNoticeCenter()
127 for i := 0; i < 40; i++ {
128 nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", ""))
129 }
130 nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", ""))
131 pending := nc.drainPending()
132 if len(pending) != 2 {
133 t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending))
134 }
135 if pending[0].Count != 40 {
136 t.Fatalf("coalesced Count = %d, want 40", pending[0].Count)
137 }
138 if got := nc.drainPending(); len(got) != 0 {
139 t.Fatalf("second drain len = %d, want 0", len(got))
140 }
141 })
142
143 t.Run("cap overflow drops the 33rd distinct code", func(t *testing.T) {
144 nc := newNoticeCenter()
145 for i := 0; i < 33; i++ {
146 nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))
147 }
148 pending := nc.drainPending()
149 if len(pending) != 32 {
150 t.Fatalf("pending len = %d, want 32 (bounded at cap)", len(pending))
151 }
152 for i, n := range pending {
153 if n.Code != domain.NoticeCode(i) {
154 t.Fatalf("pending[%d].Code = %v, want %v (first 32 distinct codes retained, in order)", i, n.Code, domain.NoticeCode(i))
155 }
156 }
157 for _, n := range pending {
158 if n.Code == domain.NoticeCode(32) {
159 t.Fatalf("pending contains code %v, which should have been dropped on overflow", domain.NoticeCode(32))
160 }
161 }
162 })
163
164 t.Run("dedup on an already-queued code still increments Count at the cap", func(t *testing.T) {
165 nc := newNoticeCenter()
166 for i := 0; i < 32; i++ {
167 nc.queueGlobal(notice(domain.NoticeCode(i), fmt.Sprintf("code %d", i), ""))
168 }
169 // The queue is now full. Re-queuing an identical notice must coalesce
170 // in place, not hit the overflow-drop branch.
171 nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", ""))
172 nc.queueGlobal(notice(domain.NoticeCode(0), "code 0", ""))
173 pending := nc.drainPending()
174 if len(pending) != 32 {
175 t.Fatalf("pending len = %d, want 32 (dedup must not grow past cap)", len(pending))
176 }
177 if pending[0].Code != domain.NoticeCode(0) || pending[0].Count != 3 {
178 t.Fatalf("pending[0] = {Code:%v Count:%d}, want {Code:%v Count:3}", pending[0].Code, pending[0].Count, domain.NoticeCode(0))
179 }
180 })
181}
182
183// --- Task 3: routing, per-client toast state, attach drain ------------------
184
185// noticeClock hands out timers the test fires explicitly by advancing time.
186// Toast TTLs are asserted from the durations recorded here, so the tests never
187// depend on wall-clock progress.
188type noticeClock struct {
189 mu sync.Mutex
190 now time.Time
191 timers []*noticeTimer
192}
193
194type noticeTimer struct {
195 ch chan time.Time
196 duration time.Duration
197 deadline time.Time
198 stopped atomic.Bool
199}
200
201func newNoticeClock() *noticeClock {
202 return ¬iceClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)}
203}
204
205func (c *noticeClock) Now() time.Time {
206 c.mu.Lock()
207 defer c.mu.Unlock()
208 return c.now
209}
210
211func (c *noticeClock) NewTimer(d time.Duration) ports.Timer {
212 c.mu.Lock()
213 defer c.mu.Unlock()
214 t := ¬iceTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)}
215 c.timers = append(c.timers, t)
216 return t
217}
218
219func (t *noticeTimer) C() <-chan time.Time { return t.ch }
220
221func (t *noticeTimer) Reset(time.Duration) bool { return false }
222
223func (t *noticeTimer) Stop() bool {
224 t.stopped.Store(true)
225 return true
226}
227
228// advance moves the clock forward and fires every live timer that came due.
229// Sends are non-blocking on a buffered channel, so no timer goroutine can wedge
230// the test goroutine.
231func (c *noticeClock) advance(d time.Duration) {
232 c.mu.Lock()
233 c.now = c.now.Add(d)
234 due := make([]*noticeTimer, 0, len(c.timers))
235 kept := make([]*noticeTimer, 0, len(c.timers))
236 for _, t := range c.timers {
237 if !t.stopped.Load() && !t.deadline.After(c.now) {
238 t.stopped.Store(true)
239 due = append(due, t)
240 continue
241 }
242 kept = append(kept, t)
243 }
244 c.timers = kept
245 now := c.now
246 c.mu.Unlock()
247 for _, t := range due {
248 select {
249 case t.ch <- now:
250 default:
251 }
252 }
253}
254
255// durations returns every TTL requested so far, in creation order.
256func (c *noticeClock) durations() []time.Duration {
257 c.mu.Lock()
258 defer c.mu.Unlock()
259 out := make([]time.Duration, 0, len(c.timers))
260 for _, t := range c.timers {
261 out = append(out, t.duration)
262 }
263 return out
264}
265
266// newNoticeFixture builds a manual session owned by one attached client, with a
267// caller-supplied clock so toast TTLs are deterministic. The session has no
268// render coordinator, so invalidateRender paints directly and every repaint is
269// observable as a frame on the returned channel.
270func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) {
271 t.Helper()
272 p, _ := newBlockingPTY(t)
273 d := newTestDaemon(t, nil, clk)
274 tr, sends := newCapturingTransport(t)
275 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
276 ac.initOverlays()
277 sctx, cancel := context.WithCancel(d.serveCtx)
278 wctx, wcancel := context.WithCancel(sctx)
279 tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
280 tb.ctx, tb.cancel = wctx, wcancel
281 for _, pane := range tb.panes {
282 pane.ctx, pane.cancel = wctx, wcancel
283 }
284 sess := &session{sessionCore: sessionCore{id: "manual", name: "work", attachments: map[*attachedClient]struct{}{ac: {}}}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}
285 ac.setSession(sess)
286 d.sessions[sess.id] = sess
287 t.Cleanup(cancel)
288 return d, sess, ac, sends
289}
290
291// visibleToasts copies the client's toast state under noticeMu.
292func visibleToasts(ac *attachedClient) ([]domain.Notification, int) {
293 rt := ac.overlays
294 rt.noticeMu.Lock()
295 defer rt.noticeMu.Unlock()
296 out := make([]domain.Notification, 0, len(rt.noticeToasts))
297 for _, toast := range rt.noticeToasts {
298 out = append(out, toast.n)
299 }
300 return out, rt.noticeOverflow
301}
302
303// awaitToastCount waits for the toast slice to reach want. The polled condition
304// only reads state; assertions stay on the test goroutine.
305func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification {
306 t.Helper()
307 require.Eventually(t, func() bool {
308 ns, _ := visibleToasts(ac)
309 return len(ns) == want
310 }, 2*time.Second, time.Millisecond, "toast count never reached %d", want)
311 got, _ := visibleToasts(ac)
312 return got
313}
314
315func TestReportErrorUserErrorBecomesNotification(t *testing.T) {
316 tests := []struct {
317 name string
318 err *domain.UserError
319 wantSev domain.NoticeSeverity
320 }{
321 {
322 name: "error severity",
323 err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")),
324 wantSev: domain.NoticeError,
325 },
326 {
327 name: "warn severity",
328 err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")),
329 wantSev: domain.NoticeWarn,
330 },
331 }
332 for _, tc := range tests {
333 t.Run(tc.name, func(t *testing.T) {
334 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
335
336 d.reportError(sess, tc.err)
337
338 hist := d.notices.history()
339 require.Len(t, hist, 1)
340 require.Equal(t, tc.err.Code, hist[0].Code)
341 require.Equal(t, tc.wantSev, hist[0].Severity)
342 require.Equal(t, tc.err.Msg, hist[0].Message)
343 require.Equal(t, sess.id, hist[0].SessionID)
344 require.Equal(t, 1, hist[0].Count)
345 // Details carry the cause chain; the message never does.
346 require.Contains(t, hist[0].Details, tc.err.Err.Error())
347 require.NotContains(t, hist[0].Message, tc.err.Err.Error())
348
349 toasts := awaitToastCount(t, ac, 1)
350 require.Equal(t, tc.err.Msg, toasts[0].Message)
351 require.Equal(t, tc.err.Code, toasts[0].Code)
352 })
353 }
354}
355
356func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) {
357 d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
358 root := errors.New("permission denied")
359 mid := fmt.Errorf("open /dev/pts/3: %w", root)
360
361 d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid))
362
363 hist := d.notices.history()
364 require.Len(t, hist, 1)
365 require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details)
366}
367
368func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) {
369 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
370 raw := errors.New("something unclassified broke")
371
372 d.reportError(sess, raw)
373
374 hist := d.notices.history()
375 require.Len(t, hist, 1)
376 require.Equal(t, domain.NoticeInternal, hist[0].Code)
377 require.Equal(t, domain.NoticeError, hist[0].Severity)
378 require.Equal(t, "internal error", hist[0].Message)
379 require.Equal(t, raw.Error(), hist[0].Details)
380
381 toasts := awaitToastCount(t, ac, 1)
382 require.Equal(t, "internal error", toasts[0].Message)
383}
384
385func TestReportErrorBenignFiltered(t *testing.T) {
386 tests := []struct {
387 name string
388 err error
389 }{
390 {name: "nil", err: nil},
391 {name: "context canceled", err: context.Canceled},
392 {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)},
393 {name: "no neighbor", err: errNoNeighbor},
394 {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)},
395 {name: "no clipboard image", err: ports.ErrNoClipboardImage},
396 {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)},
397 }
398 for _, tc := range tests {
399 t.Run(tc.name, func(t *testing.T) {
400 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
401
402 d.reportError(sess, tc.err)
403
404 require.Empty(t, d.notices.history())
405 toasts, _ := visibleToasts(ac)
406 require.Empty(t, toasts)
407 })
408 }
409}
410
411// TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an
412// error that merely reads like a sentinel must still reach history.
413func TestReportErrorNonBenignIsNeverDropped(t *testing.T) {
414 d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
415
416 d.reportError(sess, errors.New("no pane in that direction"))
417
418 require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text")
419}
420
421func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
422 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
423
424 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
425 other.initOverlays()
426 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{other: {}}}, ctx: sess.ctx, cancel: func() {}}
427 other.setSession(sess2)
428 d.mu.Lock()
429 d.sessions[sess2.id] = sess2
430 d.mu.Unlock()
431
432 d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
433
434 toasts := awaitToastCount(t, ac, 1)
435 require.Equal(t, sess.id, toasts[0].SessionID)
436 otherToasts, _ := visibleToasts(other)
437 require.Empty(t, otherToasts, "another session's client must be untouched by session-scoped notices")
438}
439
440func TestNotifySessionScopedSerializesDetachThroughToastPublication(t *testing.T) {
441 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
442
443 selected := make(chan bool, 1)
444 releasePublication := make(chan struct{})
445 d.notices.beforeSessionDelivery = func() {
446 unlocked := d.notices.routingMu.TryLock()
447 if unlocked {
448 d.notices.routingMu.Unlock()
449 }
450 selected <- !unlocked
451 <-releasePublication
452 }
453 t.Cleanup(func() {
454 select {
455 case <-releasePublication:
456 default:
457 close(releasePublication)
458 }
459 })
460
461 notifyDone := make(chan struct{})
462 go func() {
463 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
464 close(notifyDone)
465 }()
466 require.True(t, <-selected, "routingMu must remain held from attachment selection through toast publication")
467
468 detachStarted := make(chan struct{})
469 detachDone := make(chan bool, 1)
470 go func() {
471 close(detachStarted)
472 detachDone <- d.detachIfCurrent(sess, ac)
473 }()
474 <-detachStarted
475 select {
476 case <-detachDone:
477 t.Fatal("detach completed before the selected toast was durably published")
478 default:
479 }
480
481 close(releasePublication)
482 <-notifyDone
483 require.True(t, <-detachDone)
484
485 toasts, _ := visibleToasts(ac)
486 require.Len(t, toasts, 1)
487 require.Equal(t, "hello", toasts[0].Message)
488 require.Empty(t, d.notices.drainPending(), "a toast published before detach must not also be queued")
489}
490
491func TestNotifySessionScopedReleasesRoutingBeforeRepaint(t *testing.T) {
492 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
493
494 repaintObserved := make(chan bool, 1)
495 ac.renderStages.capture = func() {
496 unlocked := d.notices.routingMu.TryLock()
497 if unlocked {
498 d.notices.routingMu.Unlock()
499 }
500 repaintObserved <- unlocked
501 }
502
503 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "hello", nil)
504
505 require.True(t, <-repaintObserved, "notice repaint must run without routingMu held")
506}
507
508func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
509 d, sessA, oldA, _ := newNoticeFixture(t, newNoticeClock())
510 sessA.mu.Lock()
511 clearAttachmentsForTestLocked(sessA)
512 sessA.mu.Unlock()
513
514 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
515 clientB.initOverlays()
516 sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}}
517 clientB.setSession(sessB)
518 d.mu.Lock()
519 d.sessions[sessB.id] = sessB
520 d.mu.Unlock()
521
522 d.notify(sessA, domain.NoticeInfo, domain.NoticeUser, "hello from a script", nil)
523 // Use the same code globally to prove pending coalescing includes scope.
524 d.NotifyGlobal(domain.NoticeWarn, domain.NoticeUser, "global announcement", nil)
525
526 history := d.notices.history()
527 require.Len(t, history, 2)
528 require.Equal(t, domain.NoticeUser, history[1].Code)
529 oldToasts, _ := visibleToasts(oldA)
530 require.Empty(t, oldToasts, "a detached session must not paint a toast")
531
532 sessB.mu.Lock()
533 sessB.registerAttachmentLocked(clientB)
534 sessB.mu.Unlock()
535 d.drainPendingForFirstPaint(sessB, clientB)
536 bToasts, _ := visibleToasts(clientB)
537 require.Len(t, bToasts, 1, "another session receives only the global notice")
538 require.Equal(t, domain.NoticeUser, bToasts[0].Code)
539 require.Empty(t, bToasts[0].SessionID)
540 require.Equal(t, "global announcement", bToasts[0].Message)
541
542 sessA.mu.Lock()
543 sessA.registerAttachmentLocked(oldA)
544 sessA.mu.Unlock()
545 d.drainPendingForFirstPaint(sessA, oldA)
546 aToasts, _ := visibleToasts(oldA)
547 require.Len(t, aToasts, 1)
548 require.Equal(t, domain.NoticeUser, aToasts[0].Code)
549 require.Equal(t, sessA.id, aToasts[0].SessionID)
550 require.Equal(t, "hello from a script", aToasts[0].Message)
551 require.Equal(t, 1, aToasts[0].Count)
552
553 secondA := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
554 secondA.initOverlays()
555 secondA.setSession(sessA)
556 sessA.mu.Lock()
557 sessA.registerAttachmentLocked(secondA)
558 sessA.mu.Unlock()
559 d.drainPendingForFirstPaint(sessA, secondA)
560 secondToasts, _ := visibleToasts(secondA)
561 require.Empty(t, secondToasts, "a drained session notice must not be delivered twice")
562}
563
564func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
565 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
566
567 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
568 second.initOverlays()
569 sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", attachments: map[*attachedClient]struct{}{second: {}}}, ctx: sess.ctx, cancel: func() {}}
570 second.setSession(sess2)
571 d.mu.Lock()
572 d.sessions[sess2.id] = sess2
573 d.mu.Unlock()
574
575 d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
576
577 first := awaitToastCount(t, ac, 1)
578 require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id")
579 secondToasts := awaitToastCount(t, second, 1)
580 require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code)
581 require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")
582}
583
584func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
585 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
586 sess.mu.Lock()
587 clearAttachmentsForTestLocked(sess)
588 sess.mu.Unlock()
589
590 routeObserved := make(chan struct{})
591 releaseRoute := make(chan struct{})
592 d.notices.beforeQueueGlobal = func() {
593 close(routeObserved)
594 <-releaseRoute
595 }
596 globalDone := make(chan struct{})
597 go func() {
598 d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
599 close(globalDone)
600 }()
601 <-routeObserved // global routing has observed the detached session
602
603 // Publish the attachment and start firstPaint while global routing still
604 // owns its gate. firstPaint must wait for the queue, then drain it.
605 sess.mu.Lock()
606 sess.registerAttachmentLocked(ac)
607 sess.mu.Unlock()
608 paintDone := make(chan struct{})
609 go func() {
610 d.firstPaint(sess, ac)
611 close(paintDone)
612 }()
613
614 close(releaseRoute)
615 <-globalDone
616 <-paintDone
617 toasts, _ := visibleToasts(ac)
618 require.Len(t, toasts, 1)
619 require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
620 require.Empty(t, d.notices.drainPending())
621}
622
623func TestNotifyGlobalSerializesDetachWithDelivery(t *testing.T) {
624 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
625
626 routeObserved := make(chan struct{})
627 releaseRoute := make(chan struct{})
628 d.notices.beforeGlobalDelivery = func() {
629 close(routeObserved)
630 <-releaseRoute
631 }
632 globalDone := make(chan struct{})
633 go func() {
634 d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
635 close(globalDone)
636 }()
637 <-routeObserved // ac is selected and routingMu is held
638
639 detached := make(chan bool, 1)
640 go func() { detached <- d.detachIfCurrent(sess, ac) }()
641 close(releaseRoute)
642 <-globalDone
643 require.True(t, <-detached)
644
645 // The selected attachment received the toast before it could become stale.
646 toasts, _ := visibleToasts(ac)
647 require.Len(t, toasts, 1)
648 sess.mu.Lock()
649 require.Empty(t, sess.snapshotAttachmentsLocked())
650 sess.mu.Unlock()
651}
652
653func TestDetachedNoticeCoalescingPreservesDistinctContentAndSeverity(t *testing.T) {
654 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
655 sess.mu.Lock()
656 clearAttachmentsForTestLocked(sess)
657 sess.mu.Unlock()
658
659 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
660 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
661 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
662
663 sess.mu.Lock()
664 sess.registerAttachmentLocked(ac)
665 sess.mu.Unlock()
666 d.drainPendingForFirstPaint(sess, ac)
667
668 toasts := awaitToastCount(t, ac, 2)
669 require.Equal(t, "build delayed", toasts[0].Message)
670 require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
671 require.Equal(t, 2, toasts[0].Count, "identical detached notices coalesce")
672 require.Equal(t, "build started", toasts[1].Message)
673 require.Equal(t, domain.NoticeInfo, toasts[1].Severity)
674 require.Equal(t, 1, toasts[1].Count)
675}
676
677func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
678 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
679 sess.mu.Lock()
680 clearAttachmentsForTestLocked(sess)
681 sess.mu.Unlock()
682
683 d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
684
685 require.Len(t, d.notices.history(), 1)
686 toasts, _ := visibleToasts(ac)
687 require.Empty(t, toasts, "no attached client means nothing to paint yet")
688
689 // Re-attach and let firstPaint drain the pending queue.
690 sess.mu.Lock()
691 sess.registerAttachmentLocked(ac)
692 sess.mu.Unlock()
693 d.firstPaint(sess, ac)
694
695 drained := awaitToastCount(t, ac, 1)
696 require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)
697 require.Equal(t, "snapshot write failed", drained[0].Message)
698 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
699}
700
701// TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open
702// failure contract (Task 13): app/run.go queues this exact code and message
703// via NotifyGlobal before any client has attached, and the first attach must
704// see it via firstPaint's pending-queue drain.
705func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
706 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
707 sess.mu.Lock()
708 clearAttachmentsForTestLocked(sess)
709 sess.mu.Unlock()
710
711 cause := errors.New("open store.db: permission denied")
712 d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
713 "session persistence is disabled; sessions will not survive daemon restarts", cause)
714
715 require.Len(t, d.notices.history(), 1)
716 toasts, _ := visibleToasts(ac)
717 require.Empty(t, toasts, "no attached client means nothing to paint yet")
718
719 // Re-attach and let firstPaint drain the pending queue.
720 sess.mu.Lock()
721 sess.registerAttachmentLocked(ac)
722 sess.mu.Unlock()
723 d.firstPaint(sess, ac)
724
725 drained := awaitToastCount(t, ac, 1)
726 require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)
727 require.Equal(t, domain.NoticeWarn, drained[0].Severity)
728 require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message)
729 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
730}
731
732// levelCaptureHandler records the slog.Level of every record it's handed, so
733// tests can assert on log verbosity without parsing formatted output.
734type levelCaptureHandler struct {
735 mu sync.Mutex
736 levels []slog.Level
737}
738
739func (h *levelCaptureHandler) Enabled(context.Context, slog.Level) bool { return true }
740
741func (h *levelCaptureHandler) Handle(_ context.Context, r slog.Record) error {
742 h.mu.Lock()
743 defer h.mu.Unlock()
744 h.levels = append(h.levels, r.Level)
745 return nil
746}
747
748func (h *levelCaptureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
749func (h *levelCaptureHandler) WithGroup(string) slog.Handler { return h }
750
751func TestNotifyLogsAtSeverityLevel(t *testing.T) {
752 tests := []struct {
753 name string
754 sev domain.NoticeSeverity
755 want slog.Level
756 }{
757 {"info severity logs at info", domain.NoticeInfo, slog.LevelInfo},
758 {"warn severity logs at warn", domain.NoticeWarn, slog.LevelWarn},
759 {"error severity logs at error", domain.NoticeError, slog.LevelError},
760 }
761 for _, tt := range tests {
762 t.Run(tt.name, func(t *testing.T) {
763 d := newTestDaemon(t, nil, newNoticeClock())
764 capture := &levelCaptureHandler{}
765 d.log = slog.New(capture)
766
767 d.notify(nil, tt.sev, domain.NoticeClipboard, "msg", nil)
768
769 require.Equal(t, []slog.Level{tt.want}, capture.levels)
770 })
771 }
772}
773
774func TestShowToastCoalesceAndTrim(t *testing.T) {
775 t.Run("identical notices coalesce and renew their ttl", func(t *testing.T) {
776 clk := newNoticeClock()
777 d, sess, ac, _ := newNoticeFixture(t, clk)
778
779 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil)
780 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build finished", nil)
781
782 toasts := awaitToastCount(t, ac, 1)
783 require.Equal(t, 2, toasts[0].Count)
784 require.Equal(t, []time.Duration{4 * time.Second, 4 * time.Second}, clk.durations(), "coalescing renews the identical notice's ttl")
785 _, overflow := visibleToasts(ac)
786 require.Zero(t, overflow)
787 require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
788 })
789
790 t.Run("same code and scope preserve distinct message and severity", func(t *testing.T) {
791 clk := newNoticeClock()
792 d, sess, ac, _ := newNoticeFixture(t, clk)
793
794 d.notify(sess, domain.NoticeInfo, domain.NoticeUser, "build started", nil)
795 d.notify(sess, domain.NoticeWarn, domain.NoticeUser, "build delayed", nil)
796
797 toasts := awaitToastCount(t, ac, 2)
798 require.Equal(t, "build delayed", toasts[0].Message)
799 require.Equal(t, domain.NoticeWarn, toasts[0].Severity)
800 require.Equal(t, "build started", toasts[1].Message)
801 require.Equal(t, domain.NoticeInfo, toasts[1].Severity)
802 require.Equal(t, []time.Duration{4 * time.Second, 6 * time.Second}, clk.durations(), "each distinct notice keeps its severity ttl")
803 })
804
805 t.Run("same code different scope does not coalesce", func(t *testing.T) {
806 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
807
808 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
809 d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
810
811 toasts := awaitToastCount(t, ac, 2)
812 require.Equal(t, 1, toasts[0].Count)
813 require.Equal(t, 1, toasts[1].Count)
814 })
815
816 t.Run("four distinct codes trim to three newest first", func(t *testing.T) {
817 d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
818 codes := []domain.NoticeCode{
819 domain.NoticePaneSpawn,
820 domain.NoticeTabSpawn,
821 domain.NoticeSnapshotWrite,
822 domain.NoticeClipboard,
823 }
824 for _, c := range codes {
825 d.notify(sess, domain.NoticeError, c, c.String(), nil)
826 }
827
828 toasts := awaitToastCount(t, ac, maxVisibleToasts)
829 require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{
830 toasts[0].Code, toasts[1].Code, toasts[2].Code,
831 }, "newest first, oldest evicted")
832 _, overflow := visibleToasts(ac)
833 require.Equal(t, 1, overflow)
834 })
835}
836
837func TestNoticeTTLPerSeverity(t *testing.T) {
838 tests := []struct {
839 name string
840 sev domain.NoticeSeverity
841 want time.Duration
842 }{
843 {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second},
844 {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second},
845 {name: "error", sev: domain.NoticeError, want: 8 * time.Second},
846 }
847 for _, tc := range tests {
848 t.Run(tc.name, func(t *testing.T) {
849 require.Equal(t, tc.want, noticeTTL(tc.sev))
850
851 clk := newNoticeClock()
852 d, sess, ac, _ := newNoticeFixture(t, clk)
853 d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil)
854 awaitToastCount(t, ac, 1)
855 require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL")
856 })
857 }
858}
859
860func TestToastExpiresOnFakeClock(t *testing.T) {
861 clk := newNoticeClock()
862 d, sess, ac, sends := newNoticeFixture(t, clk)
863
864 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
865 awaitToastCount(t, ac, 1)
866 shown := awaitFrame(t, sends, ports.MsgOutput)
867 shownOutput, err := ports.UnmarshalOutput(shown.Payload)
868 require.NoError(t, err)
869 terminal := vt.NewScreen(80, 25)
870 terminal.Write(shownOutput.Data)
871
872 // Short of the 8s error TTL nothing expires.
873 clk.advance(7 * time.Second)
874 toasts, _ := visibleToasts(ac)
875 require.Len(t, toasts, 1, "toast must survive until its TTL elapses")
876
877 clk.advance(2 * time.Second)
878 awaitToastCount(t, ac, 0)
879 _, overflow := visibleToasts(ac)
880 require.Zero(t, overflow, "overflow resets once no toast is visible")
881
882 expired := awaitFrame(t, sends, ports.MsgOutput)
883 expiredOutput, err := ports.UnmarshalOutput(expired.Payload)
884 require.NoError(t, err)
885 terminal.Write(expiredOutput.Data)
886 ac.sendMu.Lock()
887 base := ac.pipelineCache.frame.Clone()
888 ac.sendMu.Unlock()
889 require.Equal(t, frameRows(base), frameRows(terminal.Frame), "fake-clock expiry must restore every old toast cell")
890}
891
892// TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
893// toast that outlived it.
894func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) {
895 clk := newNoticeClock()
896 d, sess, ac, _ := newNoticeFixture(t, clk)
897
898 d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL
899 awaitToastCount(t, ac, 1)
900 d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL
901 awaitToastCount(t, ac, 2)
902
903 clk.advance(5 * time.Second)
904
905 toasts := awaitToastCount(t, ac, 1)
906 require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed")
907}
908
909// TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an
910// expiry timer identifies its toast by entry sequence, not by code and scope.
911// Coalescing re-arms the entry under a fresh sequence, so a timer that was
912// stopped but had already slipped past its select cannot dismiss the refreshed
913// toast that replaced the one it belonged to.
914func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) {
915 clk := newNoticeClock()
916 d, sess, ac, _ := newNoticeFixture(t, clk)
917
918 d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
919 awaitToastCount(t, ac, 1)
920
921 // Re-stamp the live entry's sequence, exactly as coalescing does, without
922 // disturbing the already-armed timer. Its code and scope are unchanged.
923 rt := ac.overlays
924 rt.noticeMu.Lock()
925 rt.noticeSeq++
926 rt.noticeToasts[0].seq = rt.noticeSeq
927 rt.noticeMu.Unlock()
928
929 clk.advance(4 * time.Second)
930
931 require.Never(t, func() bool {
932 ns, _ := visibleToasts(ac)
933 return len(ns) == 0
934 }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed")
935}