remote_preview_watch_test.go RawHistoryFolder

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}

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