render_damage_receipt_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5
6	renderer "github.com/bnema/vev-vt"
7	"github.com/bnema/vev/internal/domain"
8	"github.com/bnema/vev/internal/domain/layout"
9	"github.com/bnema/vev/internal/ports"
10	"github.com/stretchr/testify/require"
11)
12
13func TestResetCaptureRefreshesPaneAfterPeerAcknowledgesSharedDamage(t *testing.T) {
14	_, sess, ac, _ := newManualSessionWithPTYs(t, nil)
15	p := sess.tabs[0].focusedPane()
16	p.mu.Lock()
17	p.screen.ClearDamage()
18	p.screen.Write([]byte("before"))
19	p.mu.Unlock()
20
21	ac.sendMu.Lock()
22	state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: false})
23	require.True(t, ok)
24	before := state.panes[0].frame.Clone()
25	ac.sendMu.Unlock()
26
27	p.mu.Lock()
28	p.screen.Write([]byte("after"))
29	damage := p.screen.CaptureDamage()
30	p.screen.AcknowledgeDamage(damage.Generation)
31	p.mu.Unlock()
32
33	ac.sendMu.Lock()
34	state, ok = captureRenderState(sess, ac, renderCaptureRequest{reset: true})
35	ac.sendMu.Unlock()
36	require.True(t, ok)
37	require.NotEqual(t, before, state.panes[0].frame, "reset capture must not reuse the pane snapshot retained before shared damage was acknowledged")
38}
39
40func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
41	_, sess, ac, _ := newManualSessionWithPTYs(t, nil)
42	p := sess.tabs[0].focusedPane()
43	p.mu.Lock()
44	p.screen.ClearDamage()
45	p.screen.Write([]byte("preview-safe"))
46	p.mu.Unlock()
47
48	require.Empty(t, ac.render.capture.receipts, "no receipt exists before any capture runs")
49
50	ac.sendMu.Lock()
51	state, ok := captureRenderState(sess, ac, renderCaptureRequest{
52		bars:        barState{},
53		overlays:    capturedOverlayRenderState{},
54		floatingCfg: domain.FloatingConfig{},
55		reset:       false,
56		lease:       nil,
57	})
58	ac.sendMu.Unlock()
59	require.True(t, ok)
60	require.Len(t, state.receipts, 1)
61	require.Same(t, p, state.receipts[0].pane, "the primary transaction owns the pane receipt")
62	p.mu.Lock()
63	require.NotEmpty(t, p.screen.Damage(), "primary capture remains non-destructive until emission commits")
64	p.mu.Unlock()
65}
66
67// These are deliberately end-to-end pipeline tests: the pending damage comes
68// from a real pane VT Write, not a hand-built FullRedraw.
69func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) {
70	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
71	tb := sess.tabs[0]
72	visible := tb.focusedPane()
73	collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})
74	tb.panes[collapsed.id] = collapsed
75	tb.tree = &layout.Tree{
76		Root:  &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id},
77		Focus: visible.id,
78	}
79	visible.screen.ClearDamage()
80	collapsed.screen.ClearDamage()
81	collapsed.screen.Write([]byte("hidden damage"))
82
83	ac.sendMu.Lock()
84	state, ok := captureRenderState(sess, ac, renderCaptureRequest{bars: barState{}})
85	require.True(t, ok)
86	collapsedReceipt := false
87	for _, receipt := range state.receipts {
88		collapsedReceipt = collapsedReceipt || receipt.pane == collapsed
89	}
90	require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
91	require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")
92	composed := composeFrame(*state, ac.render.cache, ac.render.spare)
93	require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
94
95	<-sends
96	require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt")
97}
98
99func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
100	for _, tt := range []struct {
101		name    string
102		fail    func(*composedRenderFrame, *attachedClient)
103		restore func(*session, *attachedClient, ports.ServerConnection)
104	}{
105		{
106			name: "prepare failure",
107			fail: func(c *composedRenderFrame, _ *attachedClient) {
108				// An invalid frame makes the real output.prepare reject after capture.
109				c.frame = renderer.Frame{Width: 1}
110			},
111			restore: func(_ *session, _ *attachedClient, _ ports.ServerConnection) {},
112		},
113		{
114			name: "send failure",
115			fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
116			restore: func(sess *session, ac *attachedClient, healthy ports.ServerConnection) {
117				sess.mu.Lock()
118				sess.registerAttachmentLocked(ac)
119				sess.mu.Unlock()
120				ac.setSession(sess)
121				ac.replaceTransport(healthy)
122			},
123		},
124	} {
125		t.Run(tt.name, func(t *testing.T) {
126			d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
127			// Prepare failures now schedule recovery through the coordinator;
128			// retain the failed transaction here until this test explicitly retries.
129			d.clock = newNoticeClock()
130			d.attachCoordinator(sess, nil, ac, true)
131			healthy := ac.transport()
132			p := sess.tabs[0].focusedPane()
133
134			// Prime both the renderer shadow and the committed capture cache.
135			d.paint(sess, ac, true, nil)
136			<-sends
137			p.mu.Lock()
138			p.screen.Write([]byte("\x1b[1;1Hchanged"))
139			p.mu.Unlock()
140
141			state, composed := captureComposeForReceiptTest(t, sess, ac)
142			tt.fail(&composed, ac)
143			require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
144			p.mu.Lock()
145			require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
146			p.mu.Unlock()
147
148			tt.restore(sess, ac, healthy)
149			state, composed = captureComposeForReceiptTest(t, sess, ac)
150			require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
151			frame := <-sends
152			out := unmarshalTestOutput(t, frame.Payload)
153			require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
154			p.mu.Lock()
155			require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
156			require.Equal(t, 'c', p.screen.Cell(0, 0).Rune, "retry preserves the changed VT shadow")
157			p.mu.Unlock()
158		})
159	}
160}
161
162func TestPrepareFailureNotifiesAndSchedulesRecovery(t *testing.T) {
163	clock := newNoticeClock()
164	d, sess, ac, _ := newNoticeFixture(t, clock)
165	rc := d.attachCoordinator(sess, nil, ac, true)
166	var producers []string
167	rc.opts.onInvalidate = func(inv renderInvalidation) {
168		// Both the notice repaint and the explicit recovery must re-enter only
169		// after the publication releases its attachment locks.
170		require.True(t, ac.sendMu.TryLock(), "invalidation must not run under sendMu")
171		ac.sendMu.Unlock()
172		producers = append(producers, inv.producer)
173	}
174	state, composed := captureComposeForReceiptTest(t, sess, ac)
175	// An invalid frame makes output.prepare fail after capture. The publication
176	// releases sendMu before reporting and rescheduling.
177	composed.frame = renderer.Frame{Width: 1}
178
179	require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
180
181	history := d.notices.history()
182	require.Len(t, history, 1)
183	require.Equal(t, domain.NoticeInternal, history[0].Code)
184	require.Equal(t, "display update failed", history[0].Message)
185	toasts, _ := visibleToasts(ac)
186	require.Len(t, toasts, 1)
187	require.Equal(t, "display update failed", toasts[0].Message)
188
189	rc.mu.Lock()
190	pending, reset := rc.pending, rc.pendingReset
191	rc.mu.Unlock()
192	require.True(t, pending, "prepare failure must schedule a recovery render")
193	require.True(t, reset, "recovery must invalidate the full render state")
194	require.Contains(t, producers, "render_pipeline.go:prepare-failed", "prepare failure must explicitly schedule recovery")
195	require.Contains(t, clock.durations(), urgentRenderDeadline, "notice/recovery invalidation must arm the coordinator")
196}
197
198func TestPrepareFailureFallbackOnlySuppressesRecursiveNoticePaint(t *testing.T) {
199	d, sess, ac, sends := newNoticeFixture(t, newNoticeClock())
200
201	// A failure while reportError is repainting its own toast is intentionally
202	// ignored. It must not start another notification/repaint cycle.
203	ac.prepareFailureFallback.Store(true)
204	state, composed := captureComposeForReceiptTest(t, sess, ac)
205	composed.frame = renderer.Frame{Width: 1}
206	require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
207	require.Empty(t, d.notices.history())
208	ac.prepareFailureFallback.Store(false)
209
210	// Later outer failures each notify and repaint after sendMu has been
211	// released; neither may be hidden by the prior recursive guard.
212	for want := 1; want <= 2; want++ {
213		state, composed = captureComposeForReceiptTest(t, sess, ac)
214		composed.frame = renderer.Frame{Width: 1}
215		require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
216		require.Len(t, d.notices.history(), want)
217		frame := <-sends
218		require.Equal(t, "Output", envelopeMessageName(t, frame.Payload), "outer failure %d must repaint its notice", want)
219		require.False(t, ac.prepareFailureFallback.Load())
220	}
221}
222
223func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
224	d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
225	d.paint(sess, ac, true, nil)
226	<-sends
227	p := sess.tabs[0].focusedPane()
228	p.mu.Lock()
229	p.screen.Write([]byte("\x1b[1;1Hfirst"))
230	p.mu.Unlock()
231	state, composed := captureComposeForReceiptTest(t, sess, ac)
232	p.mu.Lock()
233	p.screen.Write([]byte("\x1b[2;1Hsecond"))
234	p.mu.Unlock()
235	require.True(t, d.publishCapturedFrameForTest(sess, ac, state, composed))
236	<-sends
237	p.mu.Lock()
238	defer p.mu.Unlock()
239	require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
240}
241
242func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
243	t.Helper()
244	ac.sendMu.Lock() // the publication helper releases the transaction lock.
245	state, ok := captureRenderState(sess, ac, renderCaptureRequest{
246		bars:        barState{},
247		overlays:    capturedOverlayRenderState{},
248		floatingCfg: domain.FloatingConfig{},
249		reset:       false,
250		lease:       nil,
251	})
252	require.True(t, ok)
253	return state, composeFrame(*state, ac.render.cache, ac.render.spare)
254}

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