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