notify_test.go RawHistoryFolder

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, &noticeClock{})
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, &noticeClock{})
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 &noticeClock{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 := &noticeTimer{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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close