1package daemon
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/protocol"
19)
20
21// --- fake clock: distinguishes the watch's own interval/revalidate timers
22// from the boundedControlSend timeout timer, so a test can drive the watch
23// loop deterministically without ever needing the send-bound timer to fire.
24
25type previewTimer struct {
26 mock *portsmocks.MockTimer
27 ch chan time.Time
28 duration time.Duration
29}
30
31func (pt *previewTimer) fire() { pt.ch <- time.Time{} }
32
33type previewClockHarness struct {
34 clock *portsmocks.MockClock
35 pending chan *previewTimer
36}
37
38func newPreviewClockHarness(t *testing.T) *previewClockHarness {
39 t.Helper()
40 h := &previewClockHarness{clock: portsmocks.NewMockClock(t), pending: make(chan *previewTimer, 64)}
41 h.clock.EXPECT().Now().Return(time.Time{}).Maybe()
42 h.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
43 pt := &previewTimer{mock: portsmocks.NewMockTimer(t), ch: make(chan time.Time, 1), duration: d}
44 pt.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(pt.ch))
45 pt.mock.EXPECT().Stop().Maybe().Return(true)
46 // boundedControlSend races this timer against an already-completing
47 // SendServer call on every frame send; the test never needs to fire it.
48 if d != detachNotifyTimeout {
49 h.pending <- pt
50 }
51 return pt.mock
52 }).Maybe()
53 return h
54}
55
56func (h *previewClockHarness) awaitTimer(t *testing.T, want time.Duration) *previewTimer {
57 t.Helper()
58 select {
59 case pt := <-h.pending:
60 require.Equal(t, want, pt.duration)
61 return pt
62 case <-time.After(2 * time.Second):
63 t.Fatalf("timed out waiting for a %s watch timer", want)
64 return nil
65 }
66}
67
68// --- fake connection: ReceiveClient must block until the test simulates
69// either a client message/EOF or a server-side Close. A generated
70// portsmocks.MockServerConnection still backs SendServer/Close so frame
71// assertions decode typed protocol.ServerMessage values directly, but the
72// blocking-until-signaled ReceiveClient is hand-rolled: expressing it through
73// mock.Call plumbing would need the same channel underneath anyway.
74
75type clientRecvResult struct {
76 msg protocol.ClientMessage
77 err error
78}
79
80type previewConnHarness struct {
81 tr *portsmocks.MockServerConnection
82 frames chan protocol.RemotePreview
83 recv chan clientRecvResult
84 closed chan struct{}
85 closeOnce sync.Once
86}
87
88func newPreviewConnHarness(t *testing.T) *previewConnHarness {
89 t.Helper()
90 h := &previewConnHarness{
91 tr: portsmocks.NewMockServerConnection(t),
92 frames: make(chan protocol.RemotePreview, 16),
93 recv: make(chan clientRecvResult, 1),
94 closed: make(chan struct{}),
95 }
96 h.tr.EXPECT().ReceiveClient().RunAndReturn(func() (protocol.ClientMessage, error) {
97 select {
98 case r := <-h.recv:
99 return r.msg, r.err
100 case <-h.closed:
101 return nil, io.EOF
102 }
103 }).Maybe()
104 h.tr.EXPECT().SendServer(mock.Anything).RunAndReturn(func(message protocol.ServerMessage) error {
105 preview, ok := message.(protocol.RemotePreview)
106 if !ok {
107 return fmt.Errorf("unexpected server message %T", message)
108 }
109 h.frames <- preview
110 return nil
111 }).Maybe()
112 h.tr.EXPECT().Close().RunAndReturn(func() error {
113 h.closeOnce.Do(func() { close(h.closed) })
114 return nil
115 }).Maybe()
116 return h
117}
118
119// closeClient simulates the client itself sending a message or hanging up;
120// this is distinct from the server-side Close the daemon issues on teardown.
121func (h *previewConnHarness) closeClient(err error) {
122 select {
123 case h.recv <- clientRecvResult{err: err}:
124 default:
125 }
126}
127
128func (h *previewConnHarness) nextFrame(t *testing.T) protocol.RemotePreview {
129 t.Helper()
130 select {
131 case f := <-h.frames:
132 return f
133 case <-time.After(2 * time.Second):
134 t.Fatal("timed out waiting for a preview frame")
135 return protocol.RemotePreview{}
136 }
137}
138
139func (h *previewConnHarness) requireNoFrame(t *testing.T) {
140 t.Helper()
141 select {
142 case f := <-h.frames:
143 t.Fatalf("unexpected preview frame sent: %+v", f)
144 case <-time.After(75 * time.Millisecond):
145 }
146}
147
148func (h *previewConnHarness) awaitClosed(t *testing.T) {
149 t.Helper()
150 select {
151 case <-h.closed:
152 case <-time.After(2 * time.Second):
153 t.Fatal("transport was not closed")
154 }
155}
156
157// --- daemon/session fixture -------------------------------------------------
158
159// newPreviewSession builds a headless named session with one quiet-PTY tab per
160// id, registers it in d.sessions the same way createSessionLocked would, and
161// wires each tab/pane context like the package's other manual fixtures.
162func newPreviewSession(t *testing.T, d *Daemon, tabIDs ...string) *session {
163 t.Helper()
164 lifecycle := newTestLifecycle(t)
165 sctx, scancel := context.WithCancel(d.serveCtx)
166 tabs := make([]*tab, 0, len(tabIDs))
167 for i, id := range tabIDs {
168 wctx, wcancel := context.WithCancel(sctx)
169 tb := newTabWithStableID(id, fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 24})
170 tb.ctx, tb.cancel = wctx, wcancel
171 for _, p := range tb.panes {
172 p.ctx, p.cancel = wctx, wcancel
173 }
174 tabs = append(tabs, tb)
175 }
176 sess := &session{
177 sessionCore: sessionCore{id: "preview", name: "work", incarnation: lifecycle, attachments: map[*attachedClient]struct{}{}},
178 ctx: sctx,
179 cancel: scancel,
180 tabs: tabs,
181 }
182 for _, tb := range tabs {
183 publishTiledPaneOwners(sess, tb)
184 }
185 d.mu.Lock()
186 d.sessions[sess.id] = sess
187 d.mu.Unlock()
188 t.Cleanup(scancel)
189 return sess
190}
191
192// writePreviewContent writes directly to the pane's VT screen under pane.mu,
193// matching the lock captureRemotePreview takes around its own Snapshot call
194// so a concurrently running watch never races the write under -race.
195func writePreviewContent(t *testing.T, p *pane, seq string) {
196 t.Helper()
197 p.mu.Lock()
198 defer p.mu.Unlock()
199 p.screen.Write([]byte(seq))
200}
201
202// previewFrameText flattens a frame's cells to a plain string for substring
203// assertions; continuation cells (the right half of a wide rune) are skipped.
204func previewFrameText(preview protocol.RemotePreview) string {
205 var b strings.Builder
206 for _, cell := range preview.Cells {
207 if cell.Continuation {
208 continue
209 }
210 if cell.Rune == 0 {
211 b.WriteByte(' ')
212 } else {
213 b.WriteRune(cell.Rune)
214 }
215 }
216 return b.String()
217}
218
219func buildPreviewWatch(sess *session, tabID string, interval time.Duration) protocol.RemotePreviewWatch {
220 return protocol.RemotePreviewWatch{
221 Request: protocol.RemotePreviewRequest{
222 Version: protocol.RemotePreviewSchemaVersion,
223 Target: domain.RemoteSessionTarget{
224 Endpoint: "local", DisplayOrigin: "local",
225 LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: domain.TabStableID(tabID),
226 },
227 Width: 20, Height: 5,
228 },
229 MinInterval: interval,
230 }
231}
232
233// --- watch harness -----------------------------------------------------------
234
235type previewWatchHarness struct {
236 t *testing.T
237 d *Daemon
238 sess *session
239 tabs []*tab
240 clk *previewClockHarness
241 conn *previewConnHarness
242 done chan struct{}
243}
244
245func newPreviewWatchHarness(t *testing.T, tabIDs ...string) *previewWatchHarness {
246 t.Helper()
247 clk := newPreviewClockHarness(t)
248 d := newTestDaemonWithCleanup(t, nil, clk.clock, true)
249 sess := newPreviewSession(t, d, tabIDs...)
250 conn := newPreviewConnHarness(t)
251 return &previewWatchHarness{t: t, d: d, sess: sess, tabs: sess.tabs, clk: clk, conn: conn}
252}
253
254func (h *previewWatchHarness) start(watch protocol.RemotePreviewWatch) {
255 h.t.Helper()
256 h.done = make(chan struct{})
257 go func() {
258 h.d.serveRemotePreviewWatch(h.conn.tr, watch)
259 close(h.done)
260 }()
261}
262
263func (h *previewWatchHarness) awaitDone(timeout time.Duration) {
264 h.t.Helper()
265 select {
266 case <-h.done:
267 case <-time.After(timeout):
268 h.t.Fatal("serveRemotePreviewWatch did not return")
269 }
270}
271
272// stop ends a still-running watch through the daemon hard context, exactly
273// as daemon shutdown would, and joins the goroutine.
274func (h *previewWatchHarness) stop() {
275 h.d.hardCancel()
276 h.awaitDone(2 * time.Second)
277}
278
279// --- tests -------------------------------------------------------------------
280
281func TestServeRemotePreviewWatchSendsFirstFrameImmediately(t *testing.T) {
282 h := newPreviewWatchHarness(t, "tab-0")
283 writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
284 h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
285
286 frame := h.conn.nextFrame(t)
287 require.Equal(t, protocol.RemotePreviewOK, frame.Status)
288 require.Contains(t, previewFrameText(frame), "v1")
289
290 h.stop()
291}
292
293func TestServeRemotePreviewWatchCoalescesBurstToOneFramePerInterval(t *testing.T) {
294 h := newPreviewWatchHarness(t, "tab-0")
295 pane := h.tabs[0].focusedPane()
296 writePreviewContent(t, pane, "\x1b[H\x1b[2Jv1")
297 interval := 32 * time.Millisecond
298 h.start(buildPreviewWatch(h.sess, "tab-0", interval))
299 first := h.conn.nextFrame(t)
300 require.Contains(t, previewFrameText(first), "v1")
301
302 // A burst of writes inside one MinInterval window: only the state at the
303 // next poll boundary should ever reach the client.
304 writePreviewContent(t, pane, "\x1b[H\x1b[2Jv2")
305 writePreviewContent(t, pane, "\x1b[H\x1b[2Jv3")
306 writePreviewContent(t, pane, "\x1b[H\x1b[2Jv4")
307
308 h.clk.awaitTimer(t, interval).fire()
309 h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
310
311 second := h.conn.nextFrame(t)
312 require.Contains(t, previewFrameText(second), "v4")
313 h.conn.requireNoFrame(t)
314
315 h.stop()
316}
317
318func TestServeRemotePreviewWatchSkipsResendWhenUnchanged(t *testing.T) {
319 h := newPreviewWatchHarness(t, "tab-0")
320 pane := h.tabs[0].focusedPane()
321 writePreviewContent(t, pane, "\x1b[H\x1b[2Jsame")
322 interval := 32 * time.Millisecond
323 h.start(buildPreviewWatch(h.sess, "tab-0", interval))
324 h.conn.nextFrame(t)
325
326 h.clk.awaitTimer(t, interval).fire()
327 h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
328 // The next interval timer is armed only after the loop re-captured and
329 // compared this pass, so seeing it proves the unchanged content was
330 // already skipped rather than merely not-yet-checked.
331 h.clk.awaitTimer(t, interval)
332 h.conn.requireNoFrame(t)
333
334 h.stop()
335}
336
337func TestServeRemotePreviewWatchIgnoresBackgroundTabOutput(t *testing.T) {
338 h := newPreviewWatchHarness(t, "tab-0", "tab-1")
339 watched := h.tabs[0].focusedPane()
340 background := h.tabs[1].focusedPane()
341 writePreviewContent(t, watched, "\x1b[H\x1b[2Jwatched")
342 interval := 32 * time.Millisecond
343 h.start(buildPreviewWatch(h.sess, "tab-0", interval))
344 first := h.conn.nextFrame(t)
345 require.Contains(t, previewFrameText(first), "watched")
346
347 writePreviewContent(t, background, "\x1b[H\x1b[2Jbg-output")
348 h.clk.awaitTimer(t, interval).fire()
349 h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
350 h.clk.awaitTimer(t, interval) // proves the pass already re-captured tab-0
351 h.conn.requireNoFrame(t)
352
353 h.stop()
354}
355
356func TestServeRemotePreviewWatchCapturesSelectedTab(t *testing.T) {
357 h := newPreviewWatchHarness(t, "tab-0", "tab-1")
358 writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jactive")
359 writePreviewContent(t, h.tabs[1].focusedPane(), "\x1b[H\x1b[2Jselected")
360 h.start(buildPreviewWatch(h.sess, "tab-1", 32*time.Millisecond))
361
362 frame := h.conn.nextFrame(t)
363 require.Equal(t, protocol.RemotePreviewOK, frame.Status)
364 require.Equal(t, domain.TabStableID("tab-1"), frame.TabID)
365 require.Contains(t, previewFrameText(frame), "selected")
366 require.NotContains(t, previewFrameText(frame), "active")
367
368 h.stop()
369}
370
371func TestServeRemotePreviewWatchSendsNoSuchTargetOnceWhenSessionKilled(t *testing.T) {
372 h := newPreviewWatchHarness(t, "tab-0")
373 writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
374 interval := 32 * time.Millisecond
375 h.start(buildPreviewWatch(h.sess, "tab-0", interval))
376 h.conn.nextFrame(t)
377
378 rc := attachmentRenderCoordinator(h.sess)
379 require.NotNil(t, rc)
380 require.True(t, rc.hasPreviewSubscribers())
381
382 // Simulate a kill through the same exact-identity lookup
383 // remotePreviewSession uses, without driving the full kill workflow.
384 h.d.mu.Lock()
385 delete(h.d.sessions, h.sess.id)
386 h.d.mu.Unlock()
387
388 h.clk.awaitTimer(t, interval).fire()
389 h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
390
391 frame := h.conn.nextFrame(t)
392 require.Equal(t, protocol.RemotePreviewNoSuchTarget, frame.Status)
393 h.conn.requireNoFrame(t)
394 h.awaitDone(2 * time.Second)
395 require.False(t, rc.hasPreviewSubscribers())
396}
397
398func TestServeRemotePreviewWatchEndsOnClientClose(t *testing.T) {
399 h := newPreviewWatchHarness(t, "tab-0")
400 writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
401 h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
402 h.conn.nextFrame(t)
403
404 rc := attachmentRenderCoordinator(h.sess)
405 require.NotNil(t, rc)
406 require.True(t, rc.hasPreviewSubscribers())
407
408 h.conn.closeClient(io.EOF)
409 h.awaitDone(2 * time.Second)
410 h.conn.requireNoFrame(t)
411 require.False(t, rc.hasPreviewSubscribers())
412}
413
414func TestServeRemotePreviewWatchRejectsInvalidWatch(t *testing.T) {
415 tests := []struct {
416 name string
417 mutate func(*protocol.RemotePreviewWatch)
418 }{
419 {name: "interval below bound", mutate: func(w *protocol.RemotePreviewWatch) {
420 w.MinInterval = protocol.RemotePreviewWatchMinInterval - time.Millisecond
421 }},
422 {name: "invalid request width", mutate: func(w *protocol.RemotePreviewWatch) {
423 w.Request.Width = 0
424 }},
425 }
426 for _, tt := range tests {
427 t.Run(tt.name, func(t *testing.T) {
428 h := newPreviewWatchHarness(t, "tab-0")
429 watch := buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond)
430 tt.mutate(&watch)
431
432 h.start(watch)
433 frame := h.conn.nextFrame(t)
434 require.Equal(t, protocol.RemotePreviewMalformed, frame.Status)
435 h.conn.awaitClosed(t)
436 h.awaitDone(2 * time.Second)
437 })
438 }
439}
440
441func TestServeRemotePreviewWatchEndsOnHardContextCancel(t *testing.T) {
442 h := newPreviewWatchHarness(t, "tab-0")
443 writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
444 h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
445 h.conn.nextFrame(t)
446
447 h.d.hardCancel()
448 h.awaitDone(2 * time.Second)
449 h.conn.requireNoFrame(t)
450 h.conn.awaitClosed(t)
451}