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}