v0.3.0 99dc266

notify_test.go RawHistoryFolder

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

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